From b46eb2b22c63713768f0e213a18ad51ab9fbeb1f Mon Sep 17 00:00:00 2001 From: Rohit Gulati Date: Tue, 15 Sep 2026 10:26:48 -0700 Subject: [PATCH 1/2] feat(cyot): add guided external phone provider setup --- CYOT-Setup/.gitignore | 6 + CYOT-Setup/CYOT-Setup.psd1 | 17 + CYOT-Setup/Setup-Cyot.ps1 | 493 ++++ CYOT-Setup/docs/README.md | 234 ++ CYOT-Setup/docs/Troubleshooting.md | 55 + .../examples/customer-config.example.json | 29 + CYOT-Setup/infra/main.bicep | 43 + CYOT-Setup/infra/main.parameters.json | 24 + CYOT-Setup/infra/resources.bicep | 241 ++ .../stages/Deploy-CyotInfrastructure.ps1 | 130 + .../stages/Step1-Register-CyotApplication.ps1 | 568 +++++ .../Step2-Setup-ExternalPhoneProvider.ps1 | 2087 +++++++++++++++++ CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 | 448 ++++ CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 | 134 ++ README.md | 7 + 15 files changed, 4516 insertions(+) create mode 100644 CYOT-Setup/.gitignore create mode 100644 CYOT-Setup/CYOT-Setup.psd1 create mode 100644 CYOT-Setup/Setup-Cyot.ps1 create mode 100644 CYOT-Setup/docs/README.md create mode 100644 CYOT-Setup/docs/Troubleshooting.md create mode 100644 CYOT-Setup/examples/customer-config.example.json create mode 100644 CYOT-Setup/infra/main.bicep create mode 100644 CYOT-Setup/infra/main.parameters.json create mode 100644 CYOT-Setup/infra/resources.bicep create mode 100644 CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 create mode 100644 CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 create mode 100644 CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 create mode 100644 CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 create mode 100644 CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 diff --git a/CYOT-Setup/.gitignore b/CYOT-Setup/.gitignore new file mode 100644 index 0000000..05fd85d --- /dev/null +++ b/CYOT-Setup/.gitignore @@ -0,0 +1,6 @@ +logs/* +!logs/.gitkeep +state/* +!state/.gitkeep +policy-backups/* +!policy-backups/.gitkeep diff --git a/CYOT-Setup/CYOT-Setup.psd1 b/CYOT-Setup/CYOT-Setup.psd1 new file mode 100644 index 0000000..308c88a --- /dev/null +++ b/CYOT-Setup/CYOT-Setup.psd1 @@ -0,0 +1,17 @@ +@{ + PackageName = 'CYOT guided setup' + PackageVersion = '0.1.0' + EntryPoint = 'Setup-Cyot.ps1' + MinimumPowerShellVersion = '7.0' + Stages = @( + 'stages/Step1-Register-CyotApplication.ps1' + 'stages/Deploy-CyotInfrastructure.ps1' + 'stages/Step2-Setup-ExternalPhoneProvider.ps1' + 'stages/Step3-Set-CyotPolicy.ps1' + ) + Infrastructure = @( + 'infra/main.bicep' + 'infra/resources.bicep' + ) + RuntimeDirectories = @('logs', 'state', 'policy-backups') +} diff --git a/CYOT-Setup/Setup-Cyot.ps1 b/CYOT-Setup/Setup-Cyot.ps1 new file mode 100644 index 0000000..3f6f35a --- /dev/null +++ b/CYOT-Setup/Setup-Cyot.ps1 @@ -0,0 +1,493 @@ +#Requires -Version 7.0 + +<# +> **Produced by:** GitHub Copilot | **Session:** S0915a + +.SYNOPSIS + Guided setup for Custom OTP (CYOT) with Microsoft Entra ID. + +.DESCRIPTION + Runs application registration, endpoint setup, validation, and policy activation as one guided + experience. Each stage remains independently rerunnable. Progress is written atomically to a + local state file so an interrupted setup can resume without storing credentials or access tokens. + + Policy activation remains a separate safety gate. The live Microsoft Graph metadata contract is + checked before policy permissions are requested or a write is attempted. + +.PARAMETER Stage + Stage to run. Omit for the guided menu. All runs Register, Deploy, Validate, then Activate. + +.PARAMETER Resume + Continue with the first incomplete stage recorded in the state file. + +.PARAMETER ConfigPath + Optional JSON configuration file. Values supplied as parameters or collected by stage scripts + take precedence over omitted configuration values. + +.PARAMETER StatePath + Progress file. Defaults to state/cyot-setup-state.json beside this script. + +.PARAMETER NonInteractive + Do not display the setup menu or allow stage scripts to request missing values. + +.PARAMETER ApprovePolicyActivation + Explicitly authorizes policy activation in noninteractive mode. This does not bypass Graph schema, + concurrency, backup, or readback safeguards. + +.EXAMPLE + .\Setup-Cyot.ps1 + +.EXAMPLE + .\Setup-Cyot.ps1 -Resume + +.EXAMPLE + .\Setup-Cyot.ps1 -NonInteractive -ConfigPath .\customer-config.json +#> +[CmdletBinding()] +param( + [ValidateSet('All', 'Register', 'Deploy', 'Validate', 'Activate', 'Diagnostics')] + [string] $Stage, + + [switch] $Resume, + + [string] $ConfigPath, + + [string] $StatePath = (Join-Path $PSScriptRoot 'state/cyot-setup-state.json'), + + [switch] $NonInteractive, + + [switch] $ApprovePolicyActivation +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script:PackageRoot = $PSScriptRoot +$script:StageDirectory = Join-Path $PSScriptRoot 'stages' +$script:LogDirectory = Join-Path $PSScriptRoot 'logs' +$script:PolicyBackupDirectory = Join-Path $PSScriptRoot 'policy-backups' +$script:StageScripts = @{ + Register = Join-Path $script:StageDirectory 'Step1-Register-CyotApplication.ps1' + Infrastructure = Join-Path $script:StageDirectory 'Deploy-CyotInfrastructure.ps1' + Deploy = Join-Path $script:StageDirectory 'Step2-Setup-ExternalPhoneProvider.ps1' + Activate = Join-Path $script:StageDirectory 'Step3-Set-CyotPolicy.ps1' +} +$script:StageOrder = @('Register', 'Deploy', 'Validate', 'Activate') +$script:EventLogPath = $null + +function Protect-CyotLogText { + param([AllowEmptyString()][string] $Text) + + if ([string]::IsNullOrEmpty($Text)) { return $Text } + $safeText = $Text -replace '(?i)(Authorization\s*[:=]\s*Bearer\s+)[^\s,;]+', '$1[REDACTED]' + $safeText = $safeText -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + return $safeText +} + +function Write-CyotEvent { + param( + [ValidateSet('INFO', 'WARN', 'ERROR')] + [string] $Level, + [string] $Message + ) + + $safeMessage = Protect-CyotLogText -Text $Message + $entry = '{0:o} [{1}] {2}' -f [DateTimeOffset]::Now, $Level, $safeMessage + Add-Content -LiteralPath $script:EventLogPath -Value $entry -Encoding utf8 + Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) +} + +function Initialize-CyotWorkspace { + foreach ($directory in @($script:LogDirectory, (Split-Path -Parent $StatePath), $script:PolicyBackupDirectory)) { + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + } + $script:EventLogPath = Join-Path $script:LogDirectory "setup-cyot-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$PID.log" + New-Item -ItemType File -Path $script:EventLogPath -Force | Out-Null +} + +function ConvertTo-CyotHashtable { + param($InputObject) + + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [Collections.IDictionary]) { + $dictionary = @{} + foreach ($key in $InputObject.Keys) { $dictionary[$key] = ConvertTo-CyotHashtable $InputObject[$key] } + return $dictionary + } + if ($InputObject -is [Management.Automation.PSCustomObject]) { + $dictionary = @{} + foreach ($property in $InputObject.PSObject.Properties) { + $dictionary[$property.Name] = ConvertTo-CyotHashtable $property.Value + } + return $dictionary + } + if ($InputObject -is [Collections.IEnumerable] -and $InputObject -isnot [string]) { + return @($InputObject | ForEach-Object { ConvertTo-CyotHashtable $_ }) + } + return $InputObject +} + +function Read-CyotConfig { + if ([string]::IsNullOrWhiteSpace($ConfigPath)) { return @{} } + if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { + throw "Configuration file not found: $ConfigPath" + } + $resolvedPath = (Resolve-Path -LiteralPath $ConfigPath).Path + Write-CyotEvent -Level INFO -Message "Loading configuration from $resolvedPath." + return ConvertTo-CyotHashtable (Get-Content -LiteralPath $resolvedPath -Raw | ConvertFrom-Json) +} + +function New-CyotState { + return [ordered]@{ + schemaVersion = 1 + updatedAtUtc = [DateTime]::UtcNow.ToString('o') + tenantId = $null + applicationId = $null + subscriptionId = $null + resourceGroup = $null + functionAppName = $null + endpointUrl = $null + identifierUri = $null + encryptionKeyId = $null + certThumbprint = $null + policyUpdated = $false + completedStages = @() + } +} + +function Read-CyotState { + if (-not (Test-Path -LiteralPath $StatePath -PathType Leaf)) { return New-CyotState } + try { + $state = ConvertTo-CyotHashtable (Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json) + if ($state.schemaVersion -ne 1) { throw "Unsupported state schema version '$($state.schemaVersion)'." } + $state.completedStages = @($state.completedStages) + return $state + } + catch { + throw "Could not read state file '$StatePath'. $($_.Exception.Message)" + } +} + +function Save-CyotState { + param([Collections.IDictionary] $State) + + $State.updatedAtUtc = [DateTime]::UtcNow.ToString('o') + $stateDirectory = Split-Path -Parent $StatePath + $temporaryPath = Join-Path $stateDirectory ".cyot-state-$([Guid]::NewGuid().ToString('N')).tmp" + try { + $json = ConvertTo-Json -InputObject $State -Depth 10 + [IO.File]::WriteAllText($temporaryPath, $json, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $StatePath -Force + } + finally { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } +} + +function Get-CyotValue { + param( + [Collections.IDictionary] $Config, + [Collections.IDictionary] $State, + [string] $Name, + [string] $StateName = $Name + ) + + foreach ($sectionName in @('setup', 'registration', 'endpoint', 'activation')) { + if ($Config.Contains($sectionName) -and $Config[$sectionName] -is [Collections.IDictionary] -and + $Config[$sectionName].Contains($Name) -and $null -ne $Config[$sectionName][$Name]) { + return $Config[$sectionName][$Name] + } + } + if ($Config.Contains($Name) -and $null -ne $Config[$Name]) { return $Config[$Name] } + if ($State.Contains($StateName) -and $null -ne $State[$StateName]) { return $State[$StateName] } + return $null +} + +function Add-CyotArgument { + param([hashtable] $Arguments, [string] $Name, $Value) + + if ($null -eq $Value) { return } + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { return } + $Arguments[$Name] = $Value +} + +function Assert-CyotStageScript { + param([string] $Name) + + $path = $script:StageScripts[$Name] + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Packaged $Name stage script is missing: $path" + } + return $path +} + +function Complete-CyotStage { + param([Collections.IDictionary] $State, [string] $Name) + + if ($State.completedStages -notcontains $Name) { + $State.completedStages = @($State.completedStages) + $Name + } + Save-CyotState -State $State + Write-CyotEvent -Level INFO -Message "$Name stage completed. State saved to $StatePath." +} + +function Invoke-CyotRegister { + param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) + + $arguments = @{ LogDirectory = $script:LogDirectory } + Add-CyotArgument $arguments TenantId (Get-CyotValue $Config $State TenantId tenantId) + Add-CyotArgument $arguments ApplicationId (Get-CyotValue $Config $State ApplicationId applicationId) + Add-CyotArgument $arguments DisplayName (Get-CyotValue $Config $State DisplayName) + if ($NonInteractive) { $arguments.NonInteractive = $true } + if ((Get-CyotValue $Config $State SkipAzureLogin) -eq $true) { $arguments.SkipAzureLogin = $true } + + Write-CyotEvent -Level INFO -Message 'Starting application registration stage.' + $outputs = @(& (Assert-CyotStageScript Register) @arguments) + $applicationId = @($outputs | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-fA-F-]{36}$' }) | Select-Object -Last 1 + if ([string]::IsNullOrWhiteSpace($applicationId)) { + throw 'Registration stage did not return an application client ID.' + } + $State.applicationId = $applicationId + $tenantId = Get-CyotValue $Config $State TenantId tenantId + if ($tenantId) { $State.tenantId = $tenantId } + Complete-CyotStage $State Register +} + +function Invoke-CyotDeploy { + param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) + + if ([string]::IsNullOrWhiteSpace($State.applicationId)) { + throw 'Deploy requires applicationId. Run the Register stage first.' + } + + $infrastructureMode = Get-CyotValue $Config $State InfrastructureMode + $infrastructureResult = $null + if ($infrastructureMode -eq 'Bicep' -and [string]::IsNullOrWhiteSpace((Get-CyotValue $Config $State EndpointUrl endpointUrl))) { + $infrastructureArguments = @{} + foreach ($name in @('SubscriptionId', 'ResourceGroup', 'Location', 'EnvironmentName', 'ResourceTagName', 'ResourceTagValue', 'PlanType')) { + Add-CyotArgument $infrastructureArguments $name (Get-CyotValue $Config $State $name) + } + if ($NonInteractive) { $infrastructureArguments.NonInteractive = $true } + + Write-CyotEvent -Level INFO -Message 'Starting Bicep infrastructure deployment.' + $infrastructureOutputs = @(& (Assert-CyotStageScript Infrastructure) @infrastructureArguments) + $infrastructureResult = $infrastructureOutputs | + Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 'Infrastructure' } | + Select-Object -Last 1 + if ($null -eq $infrastructureResult) { + throw 'Bicep infrastructure deployment did not return its stage result.' + } + } + + $arguments = @{ ApplicationId = $State.applicationId; LogDirectory = $script:LogDirectory } + $parameterNames = @( + 'FunctionAppName', 'EndpointUrl', 'SubscriptionId', 'ResourceGroup', 'Location', + 'StorageAccountName', 'KeyVaultName', 'ResourceTagName', 'ResourceTagValue', 'PlanType', + 'ZipUrl', 'ZipPath', 'FunctionRoute', 'DisplayName', 'CertificatePath', 'ProviderName', + 'ProviderEndpoint', 'ProviderTimeoutMs', 'ProviderRetryIntervalMs', 'ProviderAccountName', + 'TenantId', 'ProviderTenantId', 'ProviderScope', 'OutboundIdentityName', 'StartFromStep' + ) + foreach ($name in $parameterNames) { Add-CyotArgument $arguments $name (Get-CyotValue $Config $State $name) } + if ($null -ne $infrastructureResult) { + foreach ($name in @('FunctionAppName', 'StorageAccountName', 'KeyVaultName', 'ResourceGroup', 'Location', 'PlanType')) { + $arguments[$name] = $infrastructureResult.$name + } + } + foreach ($switchName in @('NoEasyAuth', 'UseWindowsBroker')) { + if ((Get-CyotValue $Config $State $switchName) -eq $true) { $arguments[$switchName] = $true } + } + if ($NonInteractive) { $arguments.NonInteractive = $true } + + Write-CyotEvent -Level INFO -Message 'Starting endpoint deployment/configuration stage.' + $outputs = @(& (Assert-CyotStageScript Deploy) @arguments) + $result = $outputs | Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 2 } | Select-Object -Last 1 + if ($null -eq $result) { throw 'Deploy stage did not return its stage result.' } + foreach ($mapping in @{ + TenantId = 'tenantId'; EndpointUrl = 'endpointUrl'; ApplicationId = 'applicationId'; + IdentifierUri = 'identifierUri'; EncryptionKeyId = 'encryptionKeyId'; CertThumbprint = 'certThumbprint' + }.GetEnumerator()) { + if ($result.PSObject.Properties[$mapping.Key]) { $State[$mapping.Value] = $result.($mapping.Key) } + } + foreach ($mapping in @{ + SubscriptionId = 'subscriptionId'; ResourceGroup = 'resourceGroup'; FunctionAppName = 'functionAppName'; + StorageAccountName = 'storageAccountName'; KeyVaultName = 'keyVaultName'; Location = 'location'; PlanType = 'planType' + }.GetEnumerator()) { + $value = if ($arguments.Contains($mapping.Key)) { $arguments[$mapping.Key] } else { Get-CyotValue $Config $State $mapping.Key } + if ($value) { $State[$mapping.Value] = $value } + } + Complete-CyotStage $State Deploy +} + +function Invoke-CyotValidate { + param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) + + if ([string]::IsNullOrWhiteSpace($State.endpointUrl)) { + throw 'Validate requires endpointUrl. Run the Deploy stage first.' + } + $endpoint = [Uri]::new($State.endpointUrl) + if ($endpoint.Scheme -ne 'https' -or $endpoint.IsLoopback -or $endpoint.HostNameType -in @('IPv4', 'IPv6')) { + throw 'The endpoint must use a public HTTPS hostname.' + } + try { + $addresses = [Net.Dns]::GetHostAddresses($endpoint.DnsSafeHost) + Write-CyotEvent -Level INFO -Message "Endpoint DNS resolved to $($addresses.Count) address(es)." + } + catch { + throw "Endpoint DNS resolution failed for '$($endpoint.DnsSafeHost)'. $($_.Exception.Message)" + } + + $schemaArguments = @{ CheckSchemaOnly = $true } + Add-CyotArgument $schemaArguments GraphApiVersion (Get-CyotValue $Config $State GraphApiVersion) + $outputs = @(& (Assert-CyotStageScript Activate) @schemaArguments) + $schemaStatus = $outputs | Where-Object { $_.PSObject.Properties['Supported'] } | Select-Object -Last 1 + if ($null -eq $schemaStatus) { throw 'Policy stage did not return Graph schema status.' } + $State.graphSchemaSupported = [bool]$schemaStatus.Supported + $State.graphSchemaReason = $schemaStatus.Reason + Complete-CyotStage $State Validate + if (-not $schemaStatus.Supported) { + Write-CyotEvent -Level WARN -Message "CYOT policy activation is unavailable: $($schemaStatus.Reason)" + } +} + +function Invoke-CyotActivate { + param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) + + foreach ($requiredName in @('tenantId', 'applicationId', 'endpointUrl')) { + if ([string]::IsNullOrWhiteSpace($State[$requiredName])) { + throw "Activate requires $requiredName. Complete the earlier stages first." + } + } + if ($State.Contains('graphSchemaSupported') -and -not $State.graphSchemaSupported) { + Write-CyotEvent -Level WARN -Message 'Activation skipped because the validated public Graph schema does not expose CYOT.' + return + } + if ($NonInteractive -and -not $ApprovePolicyActivation) { + throw 'Noninteractive activation requires -ApprovePolicyActivation. No policy change was attempted.' + } + + $arguments = @{ + TenantId = $State.tenantId + ApplicationId = $State.applicationId + EndpointUrl = $State.endpointUrl + BackupPath = Join-Path $script:PolicyBackupDirectory "cyot-policy-before-$($State.tenantId)-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$([Guid]::NewGuid().ToString('N')).json" + } + Add-CyotArgument $arguments Migrated (Get-CyotValue $Config $State Migrated) + Add-CyotArgument $arguments GraphApiVersion (Get-CyotValue $Config $State GraphApiVersion) + if ($NonInteractive) { + $arguments.NonInteractive = $true + $arguments.ApprovePolicyActivation = $true + } + + Write-CyotEvent -Level INFO -Message 'Starting explicit CYOT policy activation stage.' + $outputs = @(& (Assert-CyotStageScript Activate) @arguments) + $result = $outputs | Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 3 } | Select-Object -Last 1 + if ($null -eq $result) { throw 'Activation stage did not return its stage result.' } + $State.policyUpdated = [bool]$result.Updated + Complete-CyotStage $State Activate +} + +function Invoke-CyotDiagnostics { + param([Collections.IDictionary] $State) + + $checks = @( + [pscustomobject]@{ Check = 'PowerShell 7+'; Passed = $PSVersionTable.PSVersion.Major -ge 7; Detail = $PSVersionTable.PSVersion.ToString() }, + [pscustomobject]@{ Check = 'Azure CLI'; Passed = $null -ne (Get-Command az -ErrorAction SilentlyContinue); Detail = 'Required for provisioned Azure endpoints' }, + [pscustomobject]@{ Check = 'Graph authentication module'; Passed = $null -ne (Get-Module -ListAvailable Microsoft.Graph.Authentication); Detail = 'Required for Entra and policy operations' }, + [pscustomobject]@{ Check = 'Graph applications module'; Passed = $null -ne (Get-Module -ListAvailable Microsoft.Graph.Applications); Detail = 'Required for application registration' }, + [pscustomobject]@{ Check = 'Register stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Register -PathType Leaf; Detail = $script:StageScripts.Register }, + [pscustomobject]@{ Check = 'Infrastructure stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Infrastructure -PathType Leaf; Detail = $script:StageScripts.Infrastructure }, + [pscustomobject]@{ Check = 'Deploy stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Deploy -PathType Leaf; Detail = $script:StageScripts.Deploy }, + [pscustomobject]@{ Check = 'Activate stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Activate -PathType Leaf; Detail = $script:StageScripts.Activate }, + [pscustomobject]@{ Check = 'State directory'; Passed = Test-Path -LiteralPath (Split-Path -Parent $StatePath) -PathType Container; Detail = Split-Path -Parent $StatePath } + ) + $checks | Format-Table -AutoSize | Out-Host + Write-CyotEvent -Level INFO -Message "Diagnostics completed: $(@($checks | Where-Object Passed).Count)/$($checks.Count) checks passed." + return $checks +} + +function Show-CyotMenu { + Write-Host @' + +CYOT guided setup + [1] Register or reuse Entra application + [2] Deploy or configure endpoint + [3] Validate deployment and Graph schema + [4] Activate CYOT policy + [A] Run all stages + [R] Resume an interrupted setup + [D] Run diagnostics + [Q] Quit +'@ + $selection = (Read-Host 'Choose an action').Trim().ToUpperInvariant() + switch ($selection) { + '1' { return 'Register' } + '2' { return 'Deploy' } + '3' { return 'Validate' } + '4' { return 'Activate' } + 'A' { return 'All' } + 'R' { return 'Resume' } + 'D' { return 'Diagnostics' } + 'Q' { return 'Quit' } + default { throw "Unknown menu selection '$selection'." } + } +} + +function Get-CyotStagesToRun { + param([string] $SelectedStage, [Collections.IDictionary] $State) + + if ($SelectedStage -eq 'All') { return $script:StageOrder } + if ($SelectedStage -eq 'Resume') { + $remaining = @($script:StageOrder | Where-Object { $State.completedStages -notcontains $_ }) + if ($remaining.Count -eq 0) { return @() } + return $remaining + } + return @($SelectedStage) +} + +Initialize-CyotWorkspace +Write-CyotEvent -Level INFO -Message "CYOT setup started. Package root: $script:PackageRoot" + +try { + $config = Read-CyotConfig + $state = Read-CyotState + $selectedStage = $Stage + if ($Resume) { $selectedStage = 'Resume' } + if ([string]::IsNullOrWhiteSpace($selectedStage)) { + if ($NonInteractive) { $selectedStage = 'All' } + else { $selectedStage = Show-CyotMenu } + } + if ($selectedStage -eq 'Quit') { + Write-CyotEvent -Level INFO -Message 'Setup cancelled before changes were requested.' + return + } + if ($selectedStage -eq 'Diagnostics') { + Invoke-CyotDiagnostics -State $state | Out-Null + return + } + + $stagesToRun = @(Get-CyotStagesToRun -SelectedStage $selectedStage -State $state) + if ($stagesToRun.Count -eq 0) { + Write-CyotEvent -Level INFO -Message 'All stages are already complete. Nothing to resume.' + return + } + foreach ($stageName in $stagesToRun) { + switch ($stageName) { + 'Register' { Invoke-CyotRegister $config $state } + 'Deploy' { Invoke-CyotDeploy $config $state } + 'Validate' { Invoke-CyotValidate $config $state } + 'Activate' { Invoke-CyotActivate $config $state } + } + } + Write-CyotEvent -Level INFO -Message "Requested workflow completed. Completed stages: $($state.completedStages -join ', ')." +} +catch { + Write-CyotEvent -Level ERROR -Message $_.Exception.Message + Write-CyotEvent -Level ERROR -Message "Failure position: $($_.InvocationInfo.PositionMessage)" + Write-Host "Resume after correcting the issue: .\Setup-Cyot.ps1 -Resume -StatePath '$StatePath'" -ForegroundColor Yellow + throw +} +finally { + Write-Host "Event log: $script:EventLogPath" -ForegroundColor DarkGray +} \ No newline at end of file diff --git a/CYOT-Setup/docs/README.md b/CYOT-Setup/docs/README.md new file mode 100644 index 0000000..841e5d7 --- /dev/null +++ b/CYOT-Setup/docs/README.md @@ -0,0 +1,234 @@ +> **Produced by:** GitHub Copilot | **Session:** S0915a + +# CYOT guided setup + +Use one entry point to register the Entra application, configure the delivery endpoint, validate the public Microsoft Graph contract, and explicitly activate the Custom OTP (CYOT) policy. + +## Prerequisites + +- PowerShell 7.0 or later +- Azure CLI for an Azure-hosted endpoint +- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and `Microsoft.Graph.Applications` +- An account that can consent to `Application.ReadWrite.All` for registration +- Authentication Policy Administrator for policy activation with `Policy.ReadWrite.AuthenticationMethod` +- Azure permissions to create or configure the selected endpoint resources + +Run local prerequisite checks without signing in: + +```powershell +.\Setup-Cyot.ps1 -Stage Diagnostics +``` + +## Step-by-step runbook + +Use a nonproduction tenant and subscription for the first live test. Run these commands from PowerShell 7 in the `Projects/CYOT-Setup` directory. + +### 1. Install and verify prerequisites + +Install the required Microsoft Graph modules for the current user: + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery -Force +Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Repository PSGallery -Force +``` + +Install Azure CLI if it isn't already available. On Windows, one supported option is: + +```powershell +winget install --exact --id Microsoft.AzureCLI +``` + +Open a new PowerShell 7 session after installing Azure CLI, then verify the tools and run the package diagnostics: + +```powershell +$PSVersionTable.PSVersion +az version +Get-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -ListAvailable +.\Setup-Cyot.ps1 -Stage Diagnostics +``` + +Continue only when all nine diagnostics pass. + +### 2. Collect the required values + +Have these values ready before starting: + +- Customer Microsoft Entra tenant ID +- Azure subscription ID +- Dedicated test resource group and Azure region +- Globally unique Function App name +- Provider tenant ID and provider API scope ending in `/.default` +- Provider API endpoint and any provider-specific account settings +- Local endpoint ZIP path or a time-limited package URL, when deploying code + +Never put passwords, client secrets, access tokens, private keys, or SAS-bearing URLs in a committed configuration file. + +### 3. Create the customer configuration + +Copy the example outside the repository's tracked files and edit every placeholder: + +```powershell +Copy-Item .\examples\customer-config.example.json "$HOME\cyot-customer-config.json" +notepad "$HOME\cyot-customer-config.json" +``` + +Keep `endpoint.infrastructureMode` set to `Bicep` for the secure infrastructure path. Remove that property to use the original Azure CLI provisioning path. Use a dedicated test resource group so cleanup can't affect unrelated resources. + +Confirm that the JSON is valid: + +```powershell +Get-Content "$HOME\cyot-customer-config.json" -Raw | ConvertFrom-Json | Out-Null +``` + +### 4. Sign in to the test tenant and subscription + +The guided stages can request Microsoft Graph sign-in when needed. Sign in to Azure CLI first and verify the selected context: + +```powershell +$tenantId = '' +$subscriptionId = '' + +az config set core.login_experience_v2=false --only-show-errors +az login --tenant $tenantId +az account set --subscription $subscriptionId +az account show --query '{tenantId:tenantId,subscriptionId:id,subscription:name}' --output table +``` + +Stop if the displayed tenant or subscription isn't the intended test environment. Bicep mode currently requires an interactive Azure user because setup assigns that user Key Vault Secrets Officer. + +### 5. Register the Microsoft Entra application + +```powershell +.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Register +``` + +Review `state/cyot-setup-state.json` and record the `applicationId`. Give that client ID to the selected provider and complete the provider's purchase and onboarding process. Don't continue until the provider supplies its tenant ID, API scope, endpoint, and any required account settings. Add those values to the customer configuration without adding secrets. + +### 6. Deploy and configure the endpoint + +```powershell +.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Deploy +``` + +Approve only the resources shown for the dedicated test environment. If the stage stops, correct the reported issue and continue from the saved state: + +```powershell +.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Resume +``` + +### 7. Verify the deployment + +Inspect the saved identifiers and completed stages: + +```powershell +$state = Get-Content .\state\cyot-setup-state.json -Raw | ConvertFrom-Json +$state | Format-List tenantId, applicationId, subscriptionId, resourceGroup, functionAppName, endpointUrl, completedStages +``` + +For an Azure-hosted endpoint, verify resource and Function App health: + +```powershell +az resource list --resource-group $state.resourceGroup --output table +az functionapp show --resource-group $state.resourceGroup --name $state.functionAppName ` + --query '{name:name,state:state,host:defaultHostName,httpsOnly:httpsOnly}' --output table +``` + +Test the endpoint using the provider or Microsoft test procedure and expected authenticated request shape. A DNS response or generic HTTP response alone doesn't prove OTP delivery works. Confirm that a test request reaches the Function, the provider accepts it, telemetry contains no secrets, and the expected OTP arrives before activation. + +### 8. Validate the public Microsoft Graph contract + +This check resolves endpoint DNS and reads public Graph metadata. It doesn't update tenant policy: + +```powershell +.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Validate +``` + +Review `graphSchemaSupported` and `graphSchemaReason` in `state/cyot-setup-state.json`. If the live public schema doesn't expose the exact CYOT contract, stop. The package deliberately won't guess a preview contract or activate a different authentication method. + +### 9. Review and approve policy activation + +Activation requires Authentication Policy Administrator and delegated `Policy.ReadWrite.AuthenticationMethod`. Run it only after endpoint testing and schema validation succeed: + +```powershell +.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Activate +``` + +Review the proposed `cyot` payload at the prompt and type `Yes` only when the tenant ID, application ID, endpoint, and migration choice are correct. Setup saves the previous value under `policy-backups/`, checks for concurrent changes, patches only `cyot`, and verifies the result by reading it back. + +### 10. Roll back or clean up a test + +There is no automatic rollback command. This is intentional because setup can reuse preexisting applications and Azure resources. + +For a policy rollback: + +1. Stop new CYOT testing and identify the exact timestamped backup under `policy-backups/`. +2. Verify its `TenantId`, `PolicyUri`, and `PreviousCyot` values with the tenant administrator. +3. Restore only the `cyot` property through the currently supported Microsoft Graph contract, then read it back and compare it with the backup. +4. If the live schema no longer exposes that contract, don't send a guessed request. Escalate to the owning Microsoft Graph or CYOT support team. + +For Azure cleanup, first confirm the resource group was created solely for this test: + +```powershell +az resource list --resource-group --output table +``` + +Only after reviewing that inventory, delete the dedicated test resource group: + +```powershell +az group delete --name --yes --no-wait +``` + +Don't delete a shared or preexisting resource group. Application registration, provider-side onboarding, certificates, and tenant policy require separate owner-approved cleanup. Keep logs, state, and policy backups until rollback and audit needs are complete; they contain identifiers but shouldn't contain credentials. + +## Guided setup + +```powershell +.\Setup-Cyot.ps1 +``` + +Choose **Run all stages** for the standard flow. The script saves each completed stage to `state/cyot-setup-state.json`. If setup stops, fix the reported issue and continue: + +```powershell +.\Setup-Cyot.ps1 -Resume +``` + +You can also run one stage: + +```powershell +.\Setup-Cyot.ps1 -Stage Register +.\Setup-Cyot.ps1 -Stage Deploy +.\Setup-Cyot.ps1 -Stage Validate +.\Setup-Cyot.ps1 -Stage Activate +``` + +## Configuration + +Copy `examples/customer-config.example.json` to a customer-specific location and replace the placeholders. Don't add passwords, access tokens, private keys, provider credentials, or SAS URLs to the file. + +```powershell +.\Setup-Cyot.ps1 -ConfigPath .\customer-config.json +``` + +Set `endpoint.infrastructureMode` to `Bicep` to provision the Function App, storage account, Key Vault, Log Analytics workspace, Application Insights, managed identity, diagnostics, and role assignments from `infra/main.bicep`. Omit the setting to retain the Azure CLI provisioning path in the endpoint stage. The deployment checks that the configured region supports the required resource providers and Premium Functions SKU before making changes. + +Bicep mode currently requires an interactive Azure user. Setup resolves that user's object ID for the Key Vault Secrets Officer assignment; service-principal deployment isn't supported. The identity-based ZIP deployment path must also be validated in a live customer subscription before production rollout. + +For unattended setup, authenticate Azure CLI and Microsoft Graph in the current process first. Policy activation additionally requires the dedicated approval switch: + +```powershell +.\Setup-Cyot.ps1 -NonInteractive -ConfigPath .\customer-config.json -ApprovePolicyActivation +``` + +Without `-ApprovePolicyActivation`, noninteractive policy activation is rejected. The switch doesn't bypass the live schema check, backup, concurrency check, or post-write verification. + +## Safety model + +- Azure resources and Microsoft Graph are separate control planes. This package coordinates both. +- Registration and deployment are idempotent and can reuse existing resources. +- Validation checks the live public Graph metadata before activation requests policy permissions. +- Activation patches only the supported `cyot` property. +- Activation saves the previous policy value under `policy-backups/` without overwriting existing files. +- State contains resource identifiers and progress only. It doesn't contain credentials or tokens. +- Logs redact common authorization headers and secret-bearing URL query values. + +See [Troubleshooting.md](Troubleshooting.md) for recovery guidance. diff --git a/CYOT-Setup/docs/Troubleshooting.md b/CYOT-Setup/docs/Troubleshooting.md new file mode 100644 index 0000000..cf4a5c6 --- /dev/null +++ b/CYOT-Setup/docs/Troubleshooting.md @@ -0,0 +1,55 @@ +> **Produced by:** GitHub Copilot | **Session:** S0915a + +# Troubleshooting + +## Start with diagnostics + +```powershell +.\Setup-Cyot.ps1 -Stage Diagnostics +``` + +Review the newest file under `logs/`. The log records stage boundaries and failure locations without intentionally recording credentials. + +## Resume after a failure + +Correct the reported problem, then run: + +```powershell +.\Setup-Cyot.ps1 -Resume +``` + +The orchestrator skips stages listed in `state/cyot-setup-state.json`. Step 2 also supports an internal `StartFromStep` value from 1 through 11 in the configuration file when recovery must continue inside endpoint provisioning. + +## Microsoft Graph sign-in is required + +Interactive runs open the normal delegated sign-in flow. For a noninteractive run, connect in the same PowerShell process with the scopes needed by the stage before launching setup. + +Registration requires `Application.ReadWrite.All`. Activation requires `Policy.ReadWrite.AuthenticationMethod` and the Authentication Policy Administrator role. + +## CYOT isn't exposed by Microsoft Graph + +Validation reads the selected public Graph metadata document. If the exact `authenticationMethodsPolicy.cyot` contract isn't present, activation stops. Don't substitute a guessed property or a different authentication method. Confirm the supported contract with Microsoft before retrying. + +## Azure CLI reports `ValueError: Not a boolean` + +Confirm the current value: + +```powershell +az config get core.login_experience_v2 +``` + +Set a literal lowercase Boolean and retry the Azure sign-in command directly: + +```powershell +az config set core.login_experience_v2=false --only-show-errors +``` + +Also check whether `AZURE_CORE_LOGIN_EXPERIENCE_V2` is set in the process, user, or machine environment. Remove an invalid override before retrying. Step 1 treats its optional Azure CLI sign-in as nonfatal; Step 2 requires a working Azure CLI session when it provisions Azure resources. + +## State is invalid + +The state file uses schema version 1. If it is truncated or manually changed, preserve it for investigation, move it out of `state/`, and rerun the required stages. Never insert credentials into the state file. + +## Policy activation was cancelled + +Cancellation leaves completed registration and endpoint changes in place. No cleanup is automatic. Rerun `-Stage Activate` when the endpoint is tested and the administrator is ready to approve the policy change. \ No newline at end of file diff --git a/CYOT-Setup/examples/customer-config.example.json b/CYOT-Setup/examples/customer-config.example.json new file mode 100644 index 0000000..747cc68 --- /dev/null +++ b/CYOT-Setup/examples/customer-config.example.json @@ -0,0 +1,29 @@ +{ + "setup": { + "tenantId": "00000000-0000-0000-0000-000000000000", + "subscriptionId": "00000000-0000-0000-0000-000000000000" + }, + "registration": { + "displayName": "Contoso CYOT application", + "skipAzureLogin": false + }, + "endpoint": { + "infrastructureMode": "Bicep", + "environmentName": "prod", + "resourceGroup": "rg-external-phone-provider", + "location": "westus2", + "functionAppName": "contoso-cyot-endpoint", + "planType": "Premium", + "functionRoute": "api/SendOtp", + "providerName": "Replace with provider name", + "providerTenantId": "00000000-0000-0000-0000-000000000000", + "providerScope": "api://provider-application-id/.default", + "providerEndpoint": "https://provider.example.com/api/send", + "resourceTagName": "Purpose", + "resourceTagValue": "Entra - External Phone Provider" + }, + "activation": { + "graphApiVersion": "beta", + "migrated": false + } +} diff --git a/CYOT-Setup/infra/main.bicep b/CYOT-Setup/infra/main.bicep new file mode 100644 index 0000000..53bd99f --- /dev/null +++ b/CYOT-Setup/infra/main.bicep @@ -0,0 +1,43 @@ +targetScope = 'subscription' + +@description('Resource group that contains the CYOT endpoint resources.') +param resourceGroupName string = 'rg-external-phone-provider' + +@description('Azure region for all CYOT endpoint resources.') +param location string + +@minLength(2) +@maxLength(12) +@description('Short environment discriminator used in deterministic resource names.') +param environmentName string = 'prod' + +param resourceTagName string = 'Purpose' +param resourceTagValue string = 'Entra - External Phone Provider' + +@description('Object ID of the operator who may write the endpoint encryption secret.') +param deployerObjectId string + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: { + '${resourceTagName}': resourceTagValue + } +} + +module endpoint 'resources.bicep' = { + name: 'cyot-endpoint-${environmentName}' + scope: resourceGroup + params: { + location: location + environmentName: environmentName + resourceTagName: resourceTagName + resourceTagValue: resourceTagValue + deployerObjectId: deployerObjectId + } +} + +output resourceGroupName string = resourceGroup.name +output functionAppName string = endpoint.outputs.functionAppName +output storageAccountName string = endpoint.outputs.storageAccountName +output keyVaultName string = endpoint.outputs.keyVaultName \ No newline at end of file diff --git a/CYOT-Setup/infra/main.parameters.json b/CYOT-Setup/infra/main.parameters.json new file mode 100644 index 0000000..d8d097d --- /dev/null +++ b/CYOT-Setup/infra/main.parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceGroupName": { + "value": "rg-external-phone-provider" + }, + "location": { + "value": "westus2" + }, + "environmentName": { + "value": "prod" + }, + "resourceTagName": { + "value": "Purpose" + }, + "resourceTagValue": { + "value": "Entra - External Phone Provider" + }, + "deployerObjectId": { + "value": "00000000-0000-0000-0000-000000000000" + } + } +} \ No newline at end of file diff --git a/CYOT-Setup/infra/resources.bicep b/CYOT-Setup/infra/resources.bicep new file mode 100644 index 0000000..46297b1 --- /dev/null +++ b/CYOT-Setup/infra/resources.bicep @@ -0,0 +1,241 @@ +param location string +param environmentName string +param resourceTagName string +param resourceTagValue string +param deployerObjectId string + +var suffix = uniqueString(subscription().id, resourceGroup().id, environmentName) +var namePrefix = 'cyot-${environmentName}' +var tags = { + '${resourceTagName}': resourceTagValue + workload: 'cyot-endpoint' + environment: environmentName +} +var blobDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') +var queueDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') +var tableDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') +var keyVaultSecretsUserRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var keyVaultSecretsOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') +var monitoringMetricsPublisherRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: '${namePrefix}-law-${suffix}' + location: location + tags: tags + properties: { + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource telemetryIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${namePrefix}-telemetry-${suffix}' + location: location + tags: tags +} + +resource insights 'Microsoft.Insights/components@2020-02-02' = { + name: '${namePrefix}-appi-${suffix}' + location: location + kind: 'web' + tags: tags + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + DisableLocalAuth: true + IngestionMode: 'LogAnalytics' + RetentionInDays: 30 + } +} + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: 'cyot${take(replace('${environmentName}${suffix}', '-', ''), 20)}' + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + allowCrossTenantReplication: false + allowSharedKeyAccess: false + defaultToOAuthAuthentication: true + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Enabled' + supportsHttpsTrafficOnly: true + } +} + +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: take('${namePrefix}-kv-${suffix}', 24) + location: location + tags: tags + properties: { + tenantId: tenant().tenantId + enableRbacAuthorization: true + enablePurgeProtection: true + enableSoftDelete: true + softDeleteRetentionInDays: 90 + publicNetworkAccess: 'Enabled' + sku: { + family: 'A' + name: 'standard' + } + } +} + +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: '${namePrefix}-plan-${suffix}' + location: location + kind: 'linux' + tags: tags + sku: { + name: 'EP1' + tier: 'ElasticPremium' + capacity: 1 + } + properties: { + reserved: true + maximumElasticWorkerCount: 3 + } +} + +resource functionApp 'Microsoft.Web/sites@2024-04-01' = { + name: take('${namePrefix}-func-${suffix}', 60) + location: location + kind: 'functionapp,linux' + tags: tags + identity: { + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${telemetryIdentity.id}': {} + } + } + properties: { + serverFarmId: plan.id + httpsOnly: true + keyVaultReferenceIdentity: telemetryIdentity.id + publicNetworkAccess: 'Enabled' + siteConfig: { + alwaysOn: true + ftpsState: 'Disabled' + http20Enabled: true + linuxFxVersion: 'NODE|24' + minTlsVersion: '1.2' + appSettings: [ + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'node' + } + { + name: 'AzureWebJobsStorage__accountName' + value: storage.name + } + { + name: 'AzureWebJobsStorage__credential' + value: 'managedidentity' + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: insights.properties.ConnectionString + } + { + name: 'APPLICATIONINSIGHTS_AUTHENTICATION_STRING' + value: 'Authorization=AAD;ClientId=${telemetryIdentity.properties.clientId}' + } + ] + } + } +} + +resource storageBlobRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, functionApp.id, blobDataContributorRoleId) + scope: storage + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: blobDataContributorRoleId + } +} + +resource storageQueueRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, functionApp.id, queueDataContributorRoleId) + scope: storage + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: queueDataContributorRoleId + } +} + +resource storageTableRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, functionApp.id, tableDataContributorRoleId) + scope: storage + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: tableDataContributorRoleId + } +} + +resource vaultReadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, telemetryIdentity.id, keyVaultSecretsUserRoleId) + scope: vault + properties: { + principalId: telemetryIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleId + } +} + +resource vaultWriteRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, deployerObjectId, keyVaultSecretsOfficerRoleId) + scope: vault + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: keyVaultSecretsOfficerRoleId + } +} + +resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(insights.id, telemetryIdentity.id, monitoringMetricsPublisherRoleId) + scope: insights + properties: { + principalId: telemetryIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: monitoringMetricsPublisherRoleId + } +} + +resource functionDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'send-to-log-analytics' + scope: functionApp + properties: { + workspaceId: workspace.id + logs: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +output functionAppName string = functionApp.name +output storageAccountName string = storage.name +output keyVaultName string = vault.name \ No newline at end of file diff --git a/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 b/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 new file mode 100644 index 0000000..bbbe561 --- /dev/null +++ b/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 @@ -0,0 +1,130 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Deploys the Azure infrastructure used by the CYOT delivery endpoint. + +.DESCRIPTION + Runs deployment preflight checks, creates or updates the resource group through a subscription- + scoped Bicep deployment, and returns the generated resource names to the guided setup script. + This stage never performs Microsoft Graph operations or policy activation. +#> +[CmdletBinding()] +param( + [string] $SubscriptionId, + [string] $ResourceGroup = 'rg-external-phone-provider', + [string] $Location = 'westus2', + [ValidatePattern('^[a-z0-9-]{2,12}$')] + [string] $EnvironmentName = 'prod', + [string] $ResourceTagName = 'Purpose', + [string] $ResourceTagValue = 'Entra - External Phone Provider', + [ValidateSet('Premium')] + [string] $PlanType = 'Premium', + [switch] $NonInteractive +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-CyotAz { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $output = & az @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Azure CLI failed: az $($Arguments -join ' ')`n$($output -join "`n")" + } + return $output +} + +function Read-CyotRequiredValue { + param([string] $Name, [string] $Value) + + if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value } + if ($NonInteractive) { throw "$Name is required in noninteractive mode." } + $enteredValue = Read-Host $Name + if ([string]::IsNullOrWhiteSpace($enteredValue)) { throw "$Name is required." } + return $enteredValue.Trim() +} + +function Test-CyotProviderLocation { + param([string] $Namespace, [string] $ResourceType, [string] $Region) + + $locations = @((Invoke-CyotAz provider show --namespace $Namespace ` + --query "resourceTypes[?resourceType=='$ResourceType'].locations[]" --output tsv)) + $normalizedRegion = $Region -replace '[^a-zA-Z0-9]', '' + return @($locations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $normalizedRegion }).Count -gt 0 +} + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required for Bicep deployment. Install Azure CLI and run az login.' +} + +$SubscriptionId = Read-CyotRequiredValue -Name SubscriptionId -Value $SubscriptionId +$ResourceGroup = Read-CyotRequiredValue -Name ResourceGroup -Value $ResourceGroup +$Location = Read-CyotRequiredValue -Name Location -Value $Location + +$account = ((Invoke-CyotAz account show --output json) -join "`n") | ConvertFrom-Json +if ($account.id -ne $SubscriptionId) { + Invoke-CyotAz account set --subscription $SubscriptionId | Out-Null + $account = ((Invoke-CyotAz account show --output json) -join "`n") | ConvertFrom-Json +} +if ($account.id -ne $SubscriptionId) { throw "Azure CLI did not select subscription '$SubscriptionId'." } + +foreach ($provider in @( + @{ Namespace = 'Microsoft.Web'; Type = 'sites' }, + @{ Namespace = 'Microsoft.Storage'; Type = 'storageAccounts' }, + @{ Namespace = 'Microsoft.KeyVault'; Type = 'vaults' }, + @{ Namespace = 'Microsoft.OperationalInsights'; Type = 'workspaces' }, + @{ Namespace = 'Microsoft.Insights'; Type = 'components' }, + @{ Namespace = 'Microsoft.ManagedIdentity'; Type = 'userAssignedIdentities' })) { + $registrationState = (Invoke-CyotAz provider show --namespace $provider.Namespace ` + --query registrationState --output tsv) -join '' + if ($registrationState -ne 'Registered') { + throw "Resource provider '$($provider.Namespace)' is not registered in subscription '$SubscriptionId'." + } + if (-not (Test-CyotProviderLocation -Namespace $provider.Namespace -ResourceType $provider.Type -Region $Location)) { + throw "Resource type '$($provider.Namespace)/$($provider.Type)' is not available in '$Location'." + } +} + +$premiumLocations = @((Invoke-CyotAz appservice list-locations --sku EP1 --linux-workers-enabled --output tsv)) +$normalizedLocation = $Location -replace '[^a-zA-Z0-9]', '' +if (-not @($premiumLocations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $normalizedLocation }).Count) { + throw "Linux Premium Functions SKU EP1 is not available in '$Location'." +} + +$deployerObjectId = (Invoke-CyotAz ad signed-in-user show --query id --output tsv) -join '' +if ([string]::IsNullOrWhiteSpace($deployerObjectId)) { + throw 'Could not resolve the signed-in Azure user for the Key Vault Secrets Officer assignment.' +} + +$templatePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'infra/main.bicep' +if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { + throw "Bicep template not found: $templatePath" +} + +$deploymentName = "cyot-$EnvironmentName-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" +if (-not $NonInteractive) { + $confirmation = Read-Host "Deploy or update CYOT infrastructure in '$ResourceGroup' ($Location)? [y/N]" + if ($confirmation -notmatch '^(?i)y(?:es)?$') { throw 'Infrastructure deployment was cancelled.' } +} + +$outputs = ((Invoke-CyotAz deployment sub create ` + --name $deploymentName ` + --location $Location ` + --template-file $templatePath ` + --parameters resourceGroupName=$ResourceGroup location=$Location environmentName=$EnvironmentName ` + resourceTagName=$ResourceTagName resourceTagValue=$ResourceTagValue deployerObjectId=$deployerObjectId ` + --query properties.outputs --output json) -join "`n") | ConvertFrom-Json + +[pscustomobject]@{ + Stage = 'Infrastructure' + SubscriptionId = $SubscriptionId + ResourceGroup = $outputs.resourceGroupName.value + Location = $Location + PlanType = 'Premium' + FunctionAppName = $outputs.functionAppName.value + StorageAccountName = $outputs.storageAccountName.value + KeyVaultName = $outputs.keyVaultName.value +} +*** End Patch \ No newline at end of file diff --git a/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 b/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 new file mode 100644 index 0000000..5fe2684 --- /dev/null +++ b/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 @@ -0,0 +1,568 @@ +#Requires -Version 7.0 + +<# +.SYNOPSIS + Stage 1 of 3: register the customer's multi-tenant CYOT application before purchasing a provider. +.DESCRIPTION + Returns only the application (client) ID as a string. Use that ID during Security Store/provider + onboarding, then pass it as -ApplicationId during resource provisioning. + Tenant and object IDs are not included in the result. Sign-in and approval prompts remain. + + When neither -ApplicationId nor -DisplayName is supplied, a guided menu lets you register or find + an application by name, reuse an application by client ID, or exit without making changes. + Supplying either parameter bypasses the menu, and -NonInteractive never displays it. + + This stage creates no Azure resources, secrets, certificates, endpoint bindings or CYOT policy. + It neither buys an offer nor grants access in the provider's tenant. The provider must complete + their onboarding, instantiate this app's service principal in their tenant and grant their API + role. Obtain their tenant ID and API scope for stage 2. + + An existing app is found by explicit client ID, or by an unambiguous display name. Changing an + existing single-tenant app to multi-tenant requires confirmation; no duplicate app is created. + This file is self-contained; it does not load or invoke any other setup script. + Only PowerShell and the Microsoft Graph modules listed above are required. +.PARAMETER TenantId + Customer tenant. Required at Graph sign-in; never inferred from an unrelated Graph session. +.PARAMETER ApplicationId + Optional existing customer application CLIENT ID to reuse. Not the application object ID. +.PARAMETER DisplayName + Name of a new application, or an exact name to reuse when -ApplicationId is absent. + Also accepts -AppName. If neither an application ID nor a name is supplied, asks the customer + to type an app name at the registration step. Supplied names are used without prompting. +.PARAMETER NonInteractive + Allow silent authentication and reuse only; fail rather than prompt for missing inputs or approval. +.PARAMETER LogDirectory + Folder for timestamped event and transcript logs. Defaults to a Logs folder beside this script. +.EXAMPLE + $appId = .\Step1-Register-CyotApplication.ps1 -TenantId + # Type your app name when asked. The result is just the client ID. + # Complete the provider purchase/onboarding using this ID; it must not change in stage 2. +.EXAMPLE + $appId = .\Step1-Register-CyotApplication.ps1 -TenantId -AppName 'Contoso CYOT' +.EXAMPLE + .\Step1-Register-CyotApplication.ps1 -TenantId -ApplicationId +.EXAMPLE + .\Step1-Register-CyotApplication.ps1 -TenantId -AppName 'Contoso CYOT' -LogDirectory C:\Logs\ExternalPhoneProvider + Writes the event log and PowerShell transcript to the customer-selected folder. +.OUTPUTS + System.String. The application (client) ID only. +#> +[CmdletBinding()] +param( + [string] $TenantId, + [string] $ApplicationId, + [Alias('AppName')] + [string] $DisplayName, + [switch] $NonInteractive, + [switch] $SkipAzureLogin, + [string] $LogDirectory = (Join-Path $PSScriptRoot 'Logs') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$script:TranscriptStarted = $false +$script:LogPath = $null +$script:AzureCliContext = $null +$script:GraphTenantId = $TenantId +$script:GraphAccountName = $null +$script:GraphRequiredScopes = @('Application.ReadWrite.All') + +function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } + +function Write-SetupEvent { + param( + [ValidateSet('INFO', 'WARN', 'ERROR')] + [string] $Level, + [string] $Message + ) + + $entry = "{0:o} [{1}] {2}" -f [DateTimeOffset]::Now, $Level, $Message + Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) + if ($script:LogPath) { Add-Content -LiteralPath $script:LogPath -Value $entry -Encoding utf8 } +} + +function Initialize-SetupLogging { + if (-not (Test-Path -LiteralPath $LogDirectory)) { + New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null + } + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $script:LogPath = Join-Path $LogDirectory "Step1-Register-CyotApplication-$timestamp.log" + $transcriptPath = Join-Path $LogDirectory "Step1-Register-CyotApplication-$timestamp.transcript.log" + New-Item -ItemType File -Path $script:LogPath -Force | Out-Null + Start-Transcript -LiteralPath $transcriptPath -Force | Out-Null + $script:TranscriptStarted = $true + Write-SetupEvent -Level INFO -Message "Detailed log: $script:LogPath" + Write-SetupEvent -Level INFO -Message "Transcript: $transcriptPath" +} + +function Import-SetupModules { + $requiredModules = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications') + foreach ($moduleName in $requiredModules) { + if (-not (Get-Module -ListAvailable -Name $moduleName)) { + if ($NonInteractive) { + throw "Required module '$moduleName' is not installed. Install it for the current user before using -NonInteractive." + } + Write-Warning "Required module '$moduleName' is not installed." + $answer = [string](Read-Host -Prompt "Install $moduleName from PowerShell Gallery for the current user? [Y/n]") + if ($answer.Trim() -and $answer.Trim() -notmatch '^(?i:y|yes)$') { + throw "Required module '$moduleName' was not installed." + } + Write-SetupEvent -Level INFO -Message "Installing PowerShell module '$moduleName' for the current user." + Install-Module -Name $moduleName -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -ErrorAction Stop + } + Import-Module -Name $moduleName -Force -ErrorAction Stop + $loadedModule = Get-Module -Name $moduleName | Sort-Object Version -Descending | Select-Object -First 1 + Write-SetupEvent -Level INFO -Message "Loaded $moduleName version $($loadedModule.Version)." + } +} + +function Connect-SetupAzureCli { + param([string] $TenantId) + + if ($SkipAzureLogin) { + Write-SetupEvent -Level INFO -Message 'Azure CLI sign-in skipped by request.' + return + } + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-SetupEvent -Level WARN -Message "Azure CLI isn't installed or isn't on PATH. Azure sign-in was skipped because Stage 1 creates no Azure resources." + return + } + if ($NonInteractive) { + $account = az account show --output json 2>$null | ConvertFrom-Json + if (-not $account -or $account.tenantId -ne $TenantId) { + Write-SetupEvent -Level WARN -Message "Azure CLI isn't signed in to tenant '$TenantId'. Azure sign-in was skipped because Stage 1 creates no Azure resources." + return + } + $script:AzureCliContext = $account + return + } + + $answer = [string](Read-Host -Prompt "Sign in to Azure CLI tenant '$TenantId' now for the later provisioning stages? [y/N]") + if ($answer.Trim() -notmatch '^(?i:y|yes)$') { + Write-SetupEvent -Level WARN -Message 'Azure CLI sign-in skipped. Stage 1 can continue, but later stages require Azure authentication.' + return + } + Write-SetupEvent -Level INFO -Message "Starting Azure CLI sign-in for tenant '$TenantId'." + # Azure CLI 2.83 can raise "ValueError: Not a boolean" when an empty environment + # override takes precedence over the valid value in the Azure CLI config file. + $loginExperienceOverride = [Environment]::GetEnvironmentVariable('AZURE_CORE_LOGIN_EXPERIENCE_V2', 'Process') + Remove-Item Env:AZURE_CORE_LOGIN_EXPERIENCE_V2 -ErrorAction SilentlyContinue + try { + az config set core.login_experience_v2=false --only-show-errors + if ($LASTEXITCODE -ne 0) { + Write-SetupEvent -Level WARN -Message 'Azure CLI compatibility configuration failed. Stage 1 will continue because it creates no Azure resources. Run this command before Step 2: az config set core.login_experience_v2=false' + return + } + Write-SetupEvent -Level INFO -Message 'Configured Azure CLI compatibility setting core.login_experience_v2=false.' + + az login --tenant $TenantId --allow-no-subscriptions --output none + $loginExitCode = $LASTEXITCODE + } + finally { + if (-not [string]::IsNullOrWhiteSpace($loginExperienceOverride)) { + $env:AZURE_CORE_LOGIN_EXPERIENCE_V2 = $loginExperienceOverride + } + } + if ($loginExitCode -ne 0) { + Write-SetupEvent -Level WARN -Message "Azure CLI sign-in failed with exit code $loginExitCode. Stage 1 will continue because it creates no Azure resources." + return + } + $script:AzureCliContext = az account show --output json 2>$null | ConvertFrom-Json + Write-SetupEvent -Level INFO -Message 'Azure CLI sign-in completed.' +} + +function Write-SetupFailure { + param([System.Management.Automation.ErrorRecord] $ErrorRecord) + + $details = @( + "Exception: $($ErrorRecord.Exception.GetType().FullName): $($ErrorRecord.Exception.Message)" + "Error ID: $($ErrorRecord.FullyQualifiedErrorId)" + "Category: $($ErrorRecord.CategoryInfo)" + "Position: $($ErrorRecord.InvocationInfo.PositionMessage)" + "Stack trace: $($ErrorRecord.ScriptStackTrace)" + ) -join [Environment]::NewLine + Write-SetupEvent -Level ERROR -Message $details +} + +function Show-ApplicationSelectionMenu { + Write-Host '' + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host ' Select how to register the CYOT application:' -ForegroundColor Cyan + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host '' + Write-Host ' [1] Register a new application or reuse an existing app by name' -ForegroundColor White + Write-Host ' [2] Reuse an existing application by client ID' -ForegroundColor White + Write-Host ' [Q] Exit without making changes' -ForegroundColor White + Write-Host '' + + while ($true) { + $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 2 / Q]')).Trim() + switch -Regex ($choice) { + '^1$' { + Write-SetupEvent -Level INFO -Message "Menu: selected application name lookup or registration." + return 'Name' + } + '^2$' { + Write-SetupEvent -Level INFO -Message 'Menu: selected existing application client ID.' + return 'ApplicationId' + } + '^(?i:q|quit|exit)$' { + Write-SetupEvent -Level INFO -Message 'Menu: selected exit; no setup changes were requested.' + return 'Exit' + } + default { Write-Warning 'Enter 1, 2, or Q.' } + } + } +} + +function Read-SetupValue { + param( + [string] $Name, + $DefaultValue, + [switch] $Required, + [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] + [string] $ValueType = 'String', + [string[]] $Choices = @(), + [string] $Hint, + [switch] $Secret + ) + + $needsInput = $Required -and + ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) + while ($true) { + $value = $DefaultValue + if ($needsInput -and -not $NonInteractive) { + $prompt = "$Name [required]" + if ($Hint) { $prompt += " - $Hint" } + if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } + + if ($Secret) { + $secureValue = Read-Host -Prompt $prompt -AsSecureString + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + $secureValue.Dispose() + } + } + else { $answer = Read-Host -Prompt $prompt } + if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } + } + + $errorText = $null + if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { + if ($Required) { $errorText = "-$Name is required." } + else { return $null } + } + else { + switch ($ValueType) { + 'Integer' { + $number = 0 + if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { + $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." + } + else { $value = $number } + } + 'Guid' { + $identifier = [Guid]::Empty + if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { + $errorText = "-$Name must be a nonempty GUID." + } + else { $value = $identifier.ToString('D') } + } + 'Scope' { + $resource = "$value" -replace '/\.default$', '' + $resourceId = [Guid]::Empty + $resourceUri = $null + $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) + $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) + if ("$value" -notmatch '/\.default$' -or + ($isGuid -and $resourceId -eq [Guid]::Empty) -or + (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or + $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or + "$value" -match '\s') { + $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." + } + } + 'Boolean' { + if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } + elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } + else { $errorText = "-$Name must be Yes or No." } + } + 'Choice' { + if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } + else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } + } + 'File' { + if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } + } + { $_ -in @('HttpsUrl', 'Url') } { + $parsedUri = $null + if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or + $parsedUri.Scheme -notin @('http', 'https') -or + ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { + $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." + } + } + 'StorageName' { + if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } + } + 'VaultName' { + if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { + $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' + } + } + } + } + + if (-not $errorText) { return $value } + if (-not $needsInput -or $NonInteractive) { throw $errorText } + Write-Warning $errorText + } +} + +function Confirm-SetupAction { + param([string] $Action, [string] $Target, [string] $Details) + + if ($NonInteractive) { + throw "Approval required to $Action '$Target'. Rerun without -NonInteractive; no automatic approval is assumed." + } + Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow + if ($script:AzureCliContext) { + Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" + } + if ($Details) { Write-Host " $Details" } + while ($true) { + $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') + $answer = $answer.Trim() + if ($answer -match '^(?i:y|yes)$') { return } + if (-not $answer -or $answer -match '^(?i:n|no)$') { + throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") + } + Write-Warning 'Enter Yes to approve, or No/empty to stop.' + } +} + +function Test-AuthenticationFailure { + param([string] $Message) + + # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. + return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + + 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + + 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + + '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + + '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + + 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') +} + +function Connect-EndpointGraph { + param([switch] $Reconnect, [string[]] $Scopes) + + if ($PSBoundParameters.ContainsKey('Scopes')) { + if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw 'Graph authentication requires at least one nonempty scope.' + } + $script:GraphRequiredScopes = $Scopes + } + + $context = Get-MgContext -ErrorAction Stop + $canReuse = $context -and $context.AuthType -eq 'Delegated' -and + $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and + $context.Environment -eq 'Global' -and + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and + (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) + + if ($Reconnect -or -not $canReuse) { + if ($NonInteractive) { + throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." + } + $connectParameters = @{ + Scopes = $script:GraphRequiredScopes + ContextScope = 'Process' + Environment = 'Global' + NoWelcome = $true + ErrorAction = 'Stop' + } + if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } + Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow + Connect-MgGraph @connectParameters | Out-Null + $context = Get-MgContext -ErrorAction Stop + } + + if (-not $context -or $context.AuthType -ne 'Delegated' -or + $context.Environment -ne 'Global' -or + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or + ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or + ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { + throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' + } + $script:GraphTenantId = $context.TenantId + $script:GraphAccountName = $context.Account +} + +function Invoke-EndpointGraph { + param([scriptblock] $Operation) + + try { + & $Operation + } + catch { + $exception = $_.Exception + $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) + while ($exception) { + if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or + ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or + ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { + $authenticationFailure = $true + } + $exception = $exception.InnerException + } + if (-not $authenticationFailure) { throw } + Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow + Connect-EndpointGraph -Reconnect + & $Operation + } +} + +function Get-CyotApplication { + param([string] $ApplicationId, [switch] $RequireMultiTenant) + + $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid + $matches = @(Invoke-EndpointGraph { + Get-MgApplication -Filter "appId eq '$ApplicationId'" -Property Id, AppId -All -ErrorAction Stop + }) + if ($matches.Count -ne 1) { + throw "Expected exactly one app registration with client ID '$ApplicationId' in tenant '$script:GraphTenantId'; found $($matches.Count). Complete app registration in the correct tenant. No replacement app will be created." + } + $application = Invoke-EndpointGraph { + Get-MgApplication -ApplicationId $matches[0].Id ` + -Property Id, AppId, DisplayName, SignInAudience, Api, IdentifierUris, KeyCredentials, TokenEncryptionKeyId ` + -ErrorAction Stop + } + if (-not $application -or $application.AppId -ne $ApplicationId) { + throw 'Graph did not return the requested application.' + } + if ($RequireMultiTenant -and $application.SignInAudience -ne 'AzureADMultipleOrgs') { + throw "Application '$ApplicationId' is not multi-tenant. Review and approve that change in the app-registration stage before provisioning." + } + return $application +} + +function Ensure-CyotEndpointServicePrincipal { + param([string] $ApplicationId) + + $principals = @(Invoke-EndpointGraph { + Get-MgServicePrincipal -Filter "appId eq '$ApplicationId'" -All -ErrorAction Stop + }) + if ($principals.Count -gt 1) { throw "Multiple service principals match application '$ApplicationId'." } + if (-not $principals.Count) { + Confirm-SetupAction -Action 'create endpoint service principal' -Target $ApplicationId ` + -Details "Tenant: $script:GraphTenantId. No permission is granted to the provider or Microsoft." + $principal = Invoke-EndpointGraph { New-MgServicePrincipal -AppId $ApplicationId -ErrorAction Stop } + } + else { $principal = $principals[0] } + if (-not $principal -or -not $principal.Id) { throw 'Graph did not return an endpoint service-principal ID.' } + if ($principal.AppRoleAssignmentRequired) { + Confirm-SetupAction -Action 'remove endpoint app-role assignment requirement' -Target $principal.Id ` + -Details 'Microsoft EPP uses its pre-authorized caller identity. Easy Auth must still restrict callers to the Microsoft EPP application.' + Invoke-EndpointGraph { + Update-MgServicePrincipal -ServicePrincipalId $principal.Id -AppRoleAssignmentRequired:$false -ErrorAction Stop + } | Out-Null + } + return $principal +} + + +try { + Initialize-SetupLogging + if (-not $NonInteractive -and + [string]::IsNullOrWhiteSpace($ApplicationId) -and + [string]::IsNullOrWhiteSpace($DisplayName)) { + $applicationSelection = Show-ApplicationSelectionMenu + if ($applicationSelection -eq 'Exit') { return } + if ($applicationSelection -eq 'ApplicationId') { + $ApplicationId = Read-SetupValue -Name ApplicationId -Required -ValueType Guid ` + -Hint 'Use the application (client) ID, not the object ID' + } + } + + Write-Step 'Stage 1: preparing prerequisites' + Import-SetupModules + + Write-Step 'Stage 1: registering the customer application' + $tenantGuid = [Guid]::Empty + $tenantIdIsValid = [Guid]::TryParse($TenantId, [ref] $tenantGuid) -and $tenantGuid -ne [Guid]::Empty + if (-not $tenantIdIsValid) { + if ($NonInteractive) { + throw '-TenantId must be supplied as a nonempty GUID when using -NonInteractive.' + } + if (-not [string]::IsNullOrWhiteSpace($TenantId)) { + Write-Warning "The supplied -TenantId '$TenantId' is not a valid nonempty GUID." + } + Write-Host ' Enter the Microsoft Entra tenant ID where the CYOT application will be registered.' -ForegroundColor Yellow + while ($true) { + $tenantAnswer = ([string](Read-Host -Prompt 'TenantId [required] - use the Directory (tenant) ID')).Trim() + $tenantGuid = [Guid]::Empty + if ([Guid]::TryParse($tenantAnswer, [ref] $tenantGuid) -and $tenantGuid -ne [Guid]::Empty) { + break + } + Write-Warning '-TenantId must be a nonempty GUID.' + } + } + $TenantId = $tenantGuid.ToString('D') + $script:GraphTenantId = $TenantId + Connect-SetupAzureCli -TenantId $TenantId + Write-Host " Entra sign-in: authenticate to tenant '$TenantId' when prompted." -ForegroundColor Yellow + Connect-EndpointGraph -Scopes @('Application.ReadWrite.All') + Write-SetupEvent -Level INFO -Message "Microsoft Entra sign-in completed for tenant '$script:GraphTenantId' as '$script:GraphAccountName'." + + $application = $null + if ([string]::IsNullOrWhiteSpace($ApplicationId)) { + $DisplayName = Read-SetupValue -Name AppName -DefaultValue $DisplayName -Required + $matches = @(Invoke-EndpointGraph { + Get-MgApplication -Filter "displayName eq '$($DisplayName.Replace("'", "''"))'" -Property Id, AppId -All -ErrorAction Stop + }) + if ($matches.Count -gt 1) { + throw "Multiple applications use display name '$DisplayName'. Supply -ApplicationId to choose one; no new app was created." + } + if ($matches.Count -eq 1) { $ApplicationId = $matches[0].AppId } + else { + Confirm-SetupAction -Action 'create multi-tenant CYOT application' -Target $DisplayName ` + -Details "Customer tenant: $TenantId. This client ID will be given to the provider. No secrets or API permissions are created." + $createdApplication = Invoke-EndpointGraph { + New-MgApplication -DisplayName $DisplayName -SignInAudience AzureADMultipleOrgs ` + -Api @{ RequestedAccessTokenVersion = 1 } -ErrorAction Stop + } + if (-not $createdApplication -or -not $createdApplication.Id -or -not $createdApplication.AppId) { + throw 'Graph did not return the new application client ID.' + } + $ApplicationId = $createdApplication.AppId + $application = $createdApplication + } + } + + if (-not $application) { $application = Get-CyotApplication -ApplicationId $ApplicationId } + if ($application.SignInAudience -ne 'AzureADMultipleOrgs') { + if ($application.SignInAudience -ne 'AzureADMyOrg') { + throw 'This app is not an organizational single- or multi-tenant app. Select a dedicated CYOT application.' + } + Confirm-SetupAction -Action 'make existing CYOT application multi-tenant' -Target $ApplicationId ` + -Details 'Other organizational tenants will be able to instantiate its service principal. The application client ID and existing credentials remain unchanged.' + Invoke-EndpointGraph { + Update-MgApplication -ApplicationId $application.Id -SignInAudience AzureADMultipleOrgs -ErrorAction Stop + } | Out-Null + $application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant + } + Ensure-CyotEndpointServicePrincipal -ApplicationId $application.AppId | Out-Null + Write-SetupEvent -Level INFO -Message "Stage 1 completed successfully for application '$($application.AppId)'." + Write-Host "`nApplication ID: $($application.AppId)" -ForegroundColor Green + Write-Host 'Save this application ID. You will need it for Step 2 and later configuration steps.' -ForegroundColor Yellow + [string] $application.AppId +} +catch { + Write-SetupFailure -ErrorRecord $_ + throw +} +finally { + if ($script:TranscriptStarted) { Stop-Transcript | Out-Null } +} diff --git a/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 b/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 new file mode 100644 index 0000000..fb5d6f4 --- /dev/null +++ b/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 @@ -0,0 +1,2087 @@ +#Requires -Version 7.0 +#Requires -Modules Microsoft.Graph.Applications, Microsoft.Graph.Authentication + +<# +.SYNOPSIS + Stage 2 of 3: configure the delivery endpoint for the CYOT application registered in stage 1. + +.DESCRIPTION + Run stage 1 first, then complete Security Store/provider onboarding with the application (client) + ID it returns. This script takes that SAME -ApplicationId and validates the existing multi-tenant + application in the customer tenant. It never creates a replacement application or selects one by + display name. + + Choose one of two endpoint modes: provide -FunctionAppName to provision an Azure Function and its + supporting resources, or provide -EndpointUrl to configure an HTTPS endpoint you already operate. + The script configures the application identifier URI, encryption certificate, endpoint metadata, + managed identities, provider settings, and telemetry required by the selected mode. + + When neither endpoint parameter is supplied, a guided menu lets you choose Azure Function + provisioning, an existing HTTPS endpoint, or exit without making changes. Supplying either + endpoint parameter bypasses the menu, and -NonInteractive never displays it. + + For a provisioned Function, adds a user-assigned managed identity and federated identity credential + to authenticate as the customer's multi-tenant app to the provider. Obtain -ProviderTenantId and + -ProviderScope from the provider after purchase. Their API role assignment is a provider-side step, + not something this script grants. The deployed package must implement the EPP_* outbound settings. + The system-assigned identity continues to handle storage and Key Vault. + + Policy activation is a SEPARATE stage after validating the deployed endpoint. This stage does not + enable CYOT or change any Graph policy. This file is self-contained; it does not load or invoke + any other setup script. Azure CLI and the Microsoft Graph modules are still required. + + Microsoft's application is first party and pre-authorized. Nothing is consented, and no + permission is granted to Microsoft anywhere in this script. + + Safe to re-run: existing objects are reused rather than duplicated. + + Azure CLI prepares separate ARM, Microsoft Graph and Key Vault tokens before provisioning. + Azure CLI and the Graph PowerShell SDK refresh expired access tokens using their own caches. + Authentication failures trigger one recovery attempt, with interactive sign-in only when silent + refresh is no longer possible. Access tokens are never printed or copied between the two clients. + + Required settings are requested only when the step that needs them is reached. Supplied values + and defaults are used without prompting; omitted optional settings are not requested. Empty input + for a missing required setting prompts again. Provider settings are collected when configuring + the Function, not before provisioning. Every new resource still requires an explicit Yes. Empty + input or No at a creation confirmation stops the script without deleting anything already created. + Existing resources are reused without a creation confirmation. + New telemetry workspaces are created explicitly in the same resource group. A soft-deleted vault + can be recovered with confirmation; the script never purges vaults or performs subscription cleanup. + + Each run writes a timestamped event log and PowerShell transcript under the script's Logs folder, + or under -LogDirectory when supplied. Failure entries include the exception type, error ID, + category, source position, and script stack trace. The script redacts common credential-bearing + values and does not intentionally log access tokens, SAS signatures, private keys, provider + credentials, or Function app-setting values. + +.PARAMETER TenantId + Optional tenant for sign-in. Inferred from the selected Azure subscription when provisioning. + Also pins the Graph PowerShell connection so app registrations are created in the same tenant. + +.PARAMETER ApplicationId + Required application CLIENT ID string from app registration, not the object ID. + The existing app must be multi-tenant and registered in the customer tenant. + +.PARAMETER ProviderTenantId + Provider's tenant, which issues the outbound provider API token. Not the customer/app tenant. + Requested when configuring the Function if missing. + +.PARAMETER ProviderScope + Provider API App ID URI or application ID followed by /.default. This is NOT the customer app ID. + +.PARAMETER OutboundIdentityName + Optional name for the user-assigned managed identity. Defaults to -outbound. + +.PARAMETER LogDirectory + Folder for timestamped event and transcript logs. Defaults to a Logs folder beside this script. + +.PARAMETER StartFromStep + Resume at a numbered step from 1 through 11. Steps before the selected step are verified and + their required state is reconstructed without repeating their changes. If a prerequisite from a + skipped step is missing, the script stops and tells you which earlier step to resume from. + +.PARAMETER DisplayName + Retained for command-line compatibility. Stage 2 selects the existing app only by -ApplicationId. + +.PARAMETER NonInteractive + Do not prompt for settings, creation approval or sign-in. Defaults and supplied values are used. + The script stops at the first step that needs a missing required input, resource-creation approval, + or interactive sign-in. Cached credentials may still refresh silently. + +.PARAMETER UseWindowsBroker + Use Azure CLI's configured Windows authentication broker rather than the browser-login + workaround for older CLI versions. Use this if your tenant requires broker-based authentication. + By default the workaround applies only while this script invokes Azure CLI; persistent CLI + configuration and the caller's environment are not changed. + +.PARAMETER FunctionAppName + Globally unique name for the Azure Function to create. If neither this nor -EndpointUrl is + supplied, the guided flow asks which endpoint mode to use and requests the corresponding value. + +.PARAMETER EndpointUrl + An HTTPS endpoint you already operate, for example https://otp.contoso.com/api/SendOtp. + Supplying this skips Azure provisioning entirely. + +.PARAMETER ZipUrl + Optional. URL of a zip package to deploy, typically the reference endpoint Microsoft publishes to + blob storage. Downloaded and pushed to the Function. + +.PARAMETER ZipPath + Optional. A local zip package, used in preference to -ZipUrl. + +.PARAMETER PlanType + FlexConsumption (default) keeps one instance always ready. Premium (EP1) is the fallback where + Flex Consumption is unavailable. Plain Consumption is deliberately not offered: its cold start + exceeds the 3.2 s delivery budget. + +.PARAMETER KeyVaultName + Key Vault to hold the encryption private key. Created if absent. Defaults to a name derived from + the Function name. The key is stored as a secret and the Function reads it through a Key Vault + reference, so the private key never appears in app settings. A matching soft-deleted vault in the + same resource group is offered for recovery, retaining its keys and secrets rather than purging it. + +.PARAMETER ResourceTagName + Tag name applied to every resource this script creates. Defaults to 'Purpose'. + +.PARAMETER ResourceTagValue + Tag value applied to every resource this script creates. Defaults to + 'Entra - External Phone Provider', which is what makes these resources findable as a set. + +.PARAMETER CertificatePath + Optional. An existing .cer/.crt public certificate to publish as the encryption key. When + omitted a self-signed certificate is created in CurrentUser\My and exported next to this script. + +.PARAMETER ProviderName + Telephony provider to use, chosen from the supported set. Written to EPP_PROVIDER_NAME. + Requested at the Function configuration step if omitted. + +.PARAMETER ProviderEndpoint + The provider's API endpoint. Supplied by the onboarding experience from the security store. + +.PARAMETER ProviderTimeoutMs + Per-call timeout against the provider, in milliseconds. From the security store. + +.PARAMETER ProviderRetryIntervalMs + Delay between provider retries, in milliseconds. From the security store. + +.PARAMETER ProviderAccountName + Your account name with the provider. Supplied by you. + +.PARAMETER NoEasyAuth + Skips App Service Authentication and leaves token validation to your function code. By default + Easy Auth is configured to reject anything that is not a Microsoft token, before your code + runs. + +.EXAMPLE + .\Step2-Setup-ExternalPhoneProvider.ps1 + Asks which endpoint to use, then requests missing required values only as each step needs them. + Defaults and optional settings do not prompt; resource creation still needs Yes. + +.EXAMPLE + .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -Location westus2 + +.EXAMPLE + .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -ZipPath .\SendOtp.zip -ProviderTenantId -ProviderScope api://provider-api/.default + +.EXAMPLE + .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -EndpointUrl https://otp.contoso.com/api/SendOtp -TenantId + +.EXAMPLE + .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -StartFromStep 9 -LogDirectory C:\Logs\ExternalPhoneProvider + Reconstructs existing state, resumes with Function configuration and deployment, and writes logs + to the customer-selected folder. +#> + +[CmdletBinding()] +param( + [string] $FunctionAppName, + + [string] $EndpointUrl, + + [string] $SubscriptionId, + + [string] $ResourceGroup = 'rg-external-phone-provider', + + [string] $Location = 'westus2', + + [string] $StorageAccountName, + + [string] $KeyVaultName, + + [string] $ResourceTagName = 'Purpose', + + [string] $ResourceTagValue = 'Entra - External Phone Provider', + + [ValidateSet('FlexConsumption', 'Premium')] + [string] $PlanType = 'FlexConsumption', + + [string] $ZipUrl, + + [string] $ZipPath, + + [string] $FunctionRoute = 'api/SendOtp', + + [string] $DisplayName = 'Contoso MFA Telephony Endpoint', + + [string] $CertificatePath, + + [string] $ProviderName, + + [string] $ProviderEndpoint, + + [int] $ProviderTimeoutMs, + + [int] $ProviderRetryIntervalMs, + + [string] $ProviderAccountName, + + [switch] $NoEasyAuth, + + [string] $TenantId, + + [switch] $NonInteractive, + + [switch] $UseWindowsBroker, + + [string] $ApplicationId, + + [string] $ProviderTenantId, + + [string] $ProviderScope, + + [string] $OutboundIdentityName, + + [string] $LogDirectory = (Join-Path $PSScriptRoot 'Logs'), + + [ValidateRange(1, 11)] + [int] $StartFromStep = 1 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Microsoft's first-party application. It reads your published key and calls your endpoint. You do +# not grant it anything: it is pre-authorized, and this value is the same in all public clouds. +$MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13' + +# The reference endpoint implementation Microsoft publishes to blob storage. Deployed when neither +# -ZipPath nor -ZipUrl is supplied. +# +# The container is private, so this URL needs a read SAS appended before it will download. Pass the +# full URL including the SAS as -ZipUrl, or replace this value with one. The token is deliberately +# not stored here: this script is handed to customers, and a SAS in it is a credential in a document. +$ReferencePackageUrl = 'https://cyote2ecodesample.blob.core.windows.net/packages/external-phone-provider-endpoint.zip' + +# TODO: replace with the published provider list before release. +# ProviderName is a selection rather than free text, so it is validated here instead of with a +# ValidateSet attribute: the list changes independently of this script and is easier to maintain in +# one place. An empty list disables the check. +$SupportedProviders = @() + +$script:AzureCliContext = $null +$script:GraphTenantId = $TenantId +$script:GraphAccountName = $null +$script:GraphRequiredScopes = @('Application.ReadWrite.All') +$script:TranscriptStarted = $false +$script:EventLogPath = $null +$script:TranscriptPath = $null +$script:AzureCliResources = @{ + Arm = 'https://management.core.windows.net/' + Graph = 'https://graph.microsoft.com' + KeyVault = 'https://vault.azure.net' +} + +function Set-FunctionAppSettings { + <# + Merges app settings into the Function. + + Deliberately a read-merge-PUT against ARM rather than 'az functionapp config appsettings + set'. A Key Vault reference contains parentheses, az.cmd is a batch wrapper, and cmd.exe + treats those as metacharacters -- passing one inline mangles the command line. Routing the + value through a request body file means no shell ever parses it. + + The ARM appsettings endpoint replaces rather than merges, so existing settings are read and + carried forward. Dropping that step would silently wipe the platform's own settings. + #> + param( + [string] $Name, + [string] $ResourceGroup, + [string] $SubscriptionId, + [hashtable] $Settings + ) + + $existingJson = (Invoke-Az functionapp config appsettings list ` + --name $Name --resource-group $ResourceGroup -o json --only-show-errors) -join "`n" + + $merged = @{} + foreach ($item in ($existingJson | ConvertFrom-Json)) { + $merged[$item.name] = $item.value + } + foreach ($key in $Settings.Keys) { + $merged[$key] = $Settings[$key] + } + + $bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "epp-appsettings-$([Guid]::NewGuid()).json" + [System.IO.File]::WriteAllText( + $bodyFile, + (@{ properties = $merged } | ConvertTo-Json -Depth 5), + [System.Text.UTF8Encoding]::new($false)) + + try { + Invoke-Az rest --method put ` + --url ("https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup" + + "/providers/Microsoft.Web/sites/$Name/config/appsettings?api-version=2022-03-01") ` + --body "@$bodyFile" ` + --headers 'Content-Type=application/json' | Out-Null + } + finally { + Remove-Item $bodyFile -Force -ErrorAction SilentlyContinue + } + + return $merged.Count +} + +function Protect-SetupLogText { + param([AllowEmptyString()][string] $Text) + + if ([string]::IsNullOrEmpty($Text)) { return $Text } + $redacted = $Text -replace '(?i)(Authorization\s*[:=]\s*Bearer\s+)[^\s,;]+', '$1[REDACTED]' + $redacted = $redacted -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + return $redacted +} + +function Write-SetupEvent { + param( + [ValidateSet('INFO', 'WARN', 'ERROR')] + [string] $Level, + [string] $Message, + [switch] $NoConsole + ) + + $safeMessage = Protect-SetupLogText -Text $Message + $entry = "{0:o} [{1}] {2}" -f [DateTimeOffset]::Now, $Level, $safeMessage + if (-not $NoConsole) { + Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) + } + if ($script:EventLogPath) { + Add-Content -LiteralPath $script:EventLogPath -Value $entry -Encoding utf8 + } +} + +function Initialize-SetupLogging { + if (-not (Test-Path -LiteralPath $LogDirectory)) { + New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null + } + + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $script:EventLogPath = Join-Path $LogDirectory "Step2-Setup-ExternalPhoneProvider-$timestamp.log" + $script:TranscriptPath = Join-Path $LogDirectory "Step2-Setup-ExternalPhoneProvider-$timestamp.transcript.log" + New-Item -ItemType File -Path $script:EventLogPath -Force | Out-Null + Start-Transcript -LiteralPath $script:TranscriptPath -Force | Out-Null + $script:TranscriptStarted = $true + Write-SetupEvent -Level INFO -Message 'Stage 2 setup started.' + Write-SetupEvent -Level INFO -Message "Event log: $script:EventLogPath" + Write-SetupEvent -Level INFO -Message "Transcript: $script:TranscriptPath" +} + +function Write-SetupFailure { + param([System.Management.Automation.ErrorRecord] $ErrorRecord) + + $details = @( + "Exception: $($ErrorRecord.Exception.GetType().FullName): $($ErrorRecord.Exception.Message)" + "Error ID: $($ErrorRecord.FullyQualifiedErrorId)" + "Category: $($ErrorRecord.CategoryInfo)" + "Position: $($ErrorRecord.InvocationInfo.PositionMessage)" + "Stack trace: $($ErrorRecord.ScriptStackTrace)" + ) -join [Environment]::NewLine + Write-SetupEvent -Level ERROR -Message $details +} + +function Write-Step { + param([string] $Text) + + Write-Host "`n=== $Text ===" -ForegroundColor Cyan + Write-SetupEvent -Level INFO -Message "Step: $Text" -NoConsole +} + +function Get-DefaultStorageAccountName { + param([string] $FunctionName) + $stem = ($FunctionName -replace '[^a-zA-Z0-9]', '').ToLowerInvariant() + if ($stem.Length -gt 18) { $stem = $stem.Substring(0, 18) } + return "${stem}eppsa" +} + +function Get-DefaultKeyVaultName { + param([string] $FunctionName) + $stem = ($FunctionName -replace '[^a-zA-Z0-9-]', '').ToLowerInvariant() + if ($stem.Length -gt 20) { $stem = $stem.Substring(0, 20) } + return "kv-$stem" +} + +function Read-SetupValue { + param( + [string] $Name, + $DefaultValue, + [switch] $Required, + [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] + [string] $ValueType = 'String', + [string[]] $Choices = @(), + [string] $Hint, + [switch] $Secret + ) + + $needsInput = $Required -and + ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) + while ($true) { + $value = $DefaultValue + if ($needsInput -and -not $NonInteractive) { + $prompt = "$Name [required]" + if ($Hint) { $prompt += " - $Hint" } + if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } + + if ($Secret) { + $secureValue = Read-Host -Prompt $prompt -AsSecureString + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + $secureValue.Dispose() + } + } + else { $answer = Read-Host -Prompt $prompt } + if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } + } + + $errorText = $null + if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { + if ($Required) { $errorText = "-$Name is required." } + else { return $null } + } + else { + switch ($ValueType) { + 'Integer' { + $number = 0 + if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { + $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." + } + else { $value = $number } + } + 'Guid' { + $identifier = [Guid]::Empty + if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { + $errorText = "-$Name must be a nonempty GUID." + } + else { $value = $identifier.ToString('D') } + } + 'Scope' { + $resource = "$value" -replace '/\.default$', '' + $resourceId = [Guid]::Empty + $resourceUri = $null + $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) + $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) + if ("$value" -notmatch '/\.default$' -or + ($isGuid -and $resourceId -eq [Guid]::Empty) -or + (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or + $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or + "$value" -match '\s') { + $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." + } + } + 'Boolean' { + if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } + elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } + else { $errorText = "-$Name must be Yes or No." } + } + 'Choice' { + if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } + else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } + } + 'File' { + if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } + } + { $_ -in @('HttpsUrl', 'Url') } { + $parsedUri = $null + if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or + $parsedUri.Scheme -notin @('http', 'https') -or + ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { + $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." + } + } + 'StorageName' { + if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } + } + 'VaultName' { + if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { + $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' + } + } + } + } + + if (-not $errorText) { return $value } + if (-not $needsInput -or $NonInteractive) { throw $errorText } + Write-Warning $errorText + } +} + +function Show-EndpointSelectionMenu { + Write-Host '' + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host ' Select the delivery endpoint to configure:' -ForegroundColor Cyan + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host '' + Write-Host ' [1] Provision a new Azure Function and supporting resources' -ForegroundColor White + Write-Host ' [2] Configure an existing HTTPS endpoint' -ForegroundColor White + Write-Host ' [Q] Exit without making changes' -ForegroundColor White + Write-Host '' + + while ($true) { + $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 2 / Q]')).Trim() + switch -Regex ($choice) { + '^1$' { + Write-SetupEvent -Level INFO -Message 'Menu: selected Azure Function provisioning.' -NoConsole + return 'Function' + } + '^2$' { + Write-SetupEvent -Level INFO -Message 'Menu: selected existing HTTPS endpoint.' -NoConsole + return 'Existing' + } + '^(?i:q|quit|exit)$' { + Write-SetupEvent -Level INFO -Message 'Menu: selected exit; no setup changes were requested.' -NoConsole + return 'Exit' + } + default { Write-Warning 'Enter 1, 2, or Q.' } + } + } +} + +function Show-ResumeSelectionMenu { + Write-Host '' + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host ' Select where Stage 2 should start:' -ForegroundColor Cyan + Write-Host ('=' * 78) -ForegroundColor DarkCyan + Write-Host '' + Write-Host ' [1] Full run, including Azure Function provisioning' -ForegroundColor White + Write-Host ' [6] Resume application configuration and key publication' -ForegroundColor White + Write-Host ' [9] Resume Function security, settings, and deployment' -ForegroundColor White + Write-Host ' [10] Resume resource tagging and completion checks' -ForegroundColor White + Write-Host '' + + while ($true) { + $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 6 / 9 / 10]')).Trim() + if ($choice -in @('1', '6', '9', '10')) { return [int]$choice } + Write-Warning 'Enter 1, 6, 9, or 10.' + } +} + +function Resolve-EndpointParameters { + param([string] $FunctionName, [string] $ExistingEndpoint) + + $useExistingEndpoint = -not [string]::IsNullOrWhiteSpace($ExistingEndpoint) + if (-not $useExistingEndpoint -and [string]::IsNullOrWhiteSpace($FunctionName)) { + if ($NonInteractive) { throw 'Supply -FunctionAppName or -EndpointUrl when using -NonInteractive.' } + $mode = Show-EndpointSelectionMenu + if ($mode -eq 'Exit') { + return [PSCustomObject]@{ + FunctionAppName = $null + EndpointUrl = $null + ProvisionFunction = $false + ExitRequested = $true + } + } + $useExistingEndpoint = $mode -eq 'Existing' + } + + if ($useExistingEndpoint) { + $ExistingEndpoint = Read-SetupValue -Name EndpointUrl -DefaultValue $ExistingEndpoint -Required -ValueType HttpsUrl + } + else { + $FunctionName = Read-SetupValue -Name FunctionAppName -DefaultValue $FunctionName -Required + $ExistingEndpoint = $null + } + return [PSCustomObject]@{ + FunctionAppName = $FunctionName + EndpointUrl = $ExistingEndpoint + ProvisionFunction = -not $useExistingEndpoint + ExitRequested = $false + } +} + +function Get-ProviderAppSettings { + param( + [string] $Name, + [string] $Endpoint, + [Nullable[int]] $TimeoutMs, + [Nullable[int]] $RetryIntervalMs, + [string] $AccountName + ) + + if ($SupportedProviders.Count) { + $Name = Read-SetupValue -Name ProviderName -DefaultValue $Name -Required -ValueType Choice -Choices $SupportedProviders + } + else { $Name = Read-SetupValue -Name ProviderName -DefaultValue $Name -Required } + $Endpoint = Read-SetupValue -Name ProviderEndpoint -DefaultValue $Endpoint -Required -ValueType Url + $TimeoutMs = Read-SetupValue -Name ProviderTimeoutMs -DefaultValue $TimeoutMs -Required -ValueType Integer + $RetryIntervalMs = Read-SetupValue -Name ProviderRetryIntervalMs -DefaultValue $RetryIntervalMs -Required -ValueType Integer + $AccountName = Read-SetupValue -Name ProviderAccountName -DefaultValue $AccountName -Required + + if ($Endpoint -notmatch '^https://') { + Write-Host ' Provider endpoint is not HTTPS. The passcode leaves your Function in clear text.' -ForegroundColor Red + } + if ($TimeoutMs -ge 3200) { + Write-Host " Provider timeout is $TimeoutMs ms, at or over Microsoft's 3.2 s budget." -ForegroundColor Yellow + Write-Host ' Safe only if you respond 2xx before calling the provider. A synchronous call will time out.' + } + return @{ + EPP_PROVIDER_NAME = $Name + EPP_PROVIDER_ENDPOINT = $Endpoint + EPP_PROVIDER_TIMEOUT_MS = "$TimeoutMs" + EPP_PROVIDER_RETRY_INTERVAL_MS = "$RetryIntervalMs" + EPP_PROVIDER_ACCOUNT_NAME = $AccountName + } +} + +function Confirm-SetupAction { + param([string] $Action, [string] $Target, [string] $Details) + + if ($NonInteractive) { + throw "Approval required to $Action '$Target'. Rerun without -NonInteractive; no automatic approval is assumed." + } + Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow + if ($script:AzureCliContext) { + Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" + } + if ($Details) { Write-Host " $Details" } + while ($true) { + $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') + $answer = $answer.Trim() + if ($answer -match '^(?i:y|yes)$') { return } + if (-not $answer -or $answer -match '^(?i:n|no)$') { + throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") + } + Write-Warning 'Enter Yes to approve, or No/empty to stop.' + } +} + +function Test-AuthenticationFailure { + param([string] $Message) + + # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. + return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + + 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + + 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + + '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + + '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + + 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') +} + +function Invoke-AzCommand { + param([string[]] $Arguments, [switch] $Interactive) + + $previousBroker = [Environment]::GetEnvironmentVariable('AZURE_CORE_ENABLE_BROKER_ON_WINDOWS', 'Process') + try { + if ($IsWindows -and -not $UseWindowsBroker) { + $env:AZURE_CORE_ENABLE_BROKER_ON_WINDOWS = 'false' + } + + # Handle native exit codes ourselves, including when the caller has enabled this preference. + $PSNativeCommandUseErrorActionPreference = $false + if ($Interactive) { + # Do not capture subscription-selector or sign-in prompts. Login uses --output none. + & az @Arguments | Out-Host + $exitCode = $LASTEXITCODE + $lines = @() + } + else { + $output = & az @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $lines = @($output | ForEach-Object { "$_" } | + Where-Object { $_ -notmatch 'UserWarning|site-packages' }) + } + } + finally { + if ($IsWindows -and -not $UseWindowsBroker) { + [Environment]::SetEnvironmentVariable( + 'AZURE_CORE_ENABLE_BROKER_ON_WINDOWS', $previousBroker, 'Process') + } + } + + return [PSCustomObject]@{ ExitCode = $exitCode; Lines = $lines } +} + +function Assert-AzCommandSucceeded { + param($Result, [string[]] $Arguments) + + if ($Result.ExitCode -eq 0) { return } + + $operation = @() + foreach ($argument in $Arguments) { + if ($argument.StartsWith('-')) { break } + $operation += $argument + } + $message = $Result.Lines -join [Environment]::NewLine + $redact = $false + foreach ($argument in $Arguments) { + if ($argument.StartsWith('--')) { + $redact = $argument -in @('--value', '--settings', '--body', '--headers', + '--password', '--access-token', '--connection-string') + } + elseif ($redact -and $argument) { + $message = $message.Replace($argument, '') + } + } + + # Never include the complete command: some callers pass a private key or app settings. + throw "az $($operation -join ' ') failed (exit $($Result.ExitCode)):`n$message" +} + +function Get-AzureCliAccountResult { + $arguments = @('account', 'show', '--output', 'json', '--only-show-errors') + $selectedSubscription = if ($script:AzureCliContext) { $script:AzureCliContext.id } else { $SubscriptionId } + if ($selectedSubscription) { $arguments += @('--subscription', $selectedSubscription) } + return Invoke-AzCommand -Arguments $arguments +} + +function Connect-AzureCliSession { + param([string] $Resource) + + if ($NonInteractive) { + throw 'Azure CLI needs interactive sign-in. Run az login for the target tenant, or rerun without -NonInteractive. MFA and tenant policies cannot be refreshed silently.' + } + + $arguments = @('login', '--output', 'none', '--only-show-errors') + $targetTenant = if ($script:AzureCliContext) { $script:AzureCliContext.tenantId } else { $TenantId } + if ($targetTenant) { $arguments += @('--tenant', $targetTenant) } + if ($Resource) { $arguments += @('--scope', "$Resource/.default") } + Write-Host ' Azure sign-in: complete the sign-in/MFA prompt using the original provisioning account.' -ForegroundColor Yellow + $result = Invoke-AzCommand -Arguments $arguments -Interactive + Assert-AzCommandSucceeded -Result $result -Arguments $arguments + + if ($script:AzureCliContext) { + $result = Get-AzureCliAccountResult + Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'show') + $account = ($result.Lines -join "`n") | ConvertFrom-Json + if ($account.id -ne $script:AzureCliContext.id -or + $account.tenantId -ne $script:AzureCliContext.tenantId -or + $account.user.type -ne $script:AzureCliContext.user.type -or + $account.user.name -ne $script:AzureCliContext.user.name) { + throw 'Azure sign-in changed the subscription, tenant or account. Sign in with the original provisioning account before rerunning.' + } + $arguments = @('account', 'set', '--subscription', $script:AzureCliContext.id) + $result = Invoke-AzCommand -Arguments $arguments + Assert-AzCommandSucceeded -Result $result -Arguments $arguments + } +} + +function Get-AzureCliTokenResult { + param([ValidateSet('Arm', 'Graph', 'KeyVault')] [string] $ResourceName) + + # MSAL returns a usable cached token or refreshes it. Suppress the entire token response. + return Invoke-AzCommand -Arguments @('account', 'get-access-token', + '--subscription', $script:AzureCliContext.id, + '--resource', $script:AzureCliResources[$ResourceName], '--output', 'none', '--only-show-errors') +} + +function Ensure-AzureCliToken { + param([ValidateSet('Arm', 'Graph', 'KeyVault')] [string] $ResourceName) + + $result = Get-AzureCliTokenResult -ResourceName $ResourceName + if ($result.ExitCode -ne 0 -and (Test-AuthenticationFailure ($result.Lines -join "`n"))) { + Connect-AzureCliSession -Resource $script:AzureCliResources[$ResourceName] + $result = Get-AzureCliTokenResult -ResourceName $ResourceName + } + Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'get-access-token') +} + +function Assert-AzureCliTokens { + # No recursive recovery here: stop rather than alternating Graph/ARM sign-ins indefinitely. + foreach ($resourceName in @('Arm', 'Graph', 'KeyVault')) { + $result = Get-AzureCliTokenResult -ResourceName $resourceName + Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'get-access-token') + } +} + +function Initialize-AzureCliAuthentication { + $result = Get-AzureCliAccountResult + if ($result.ExitCode -ne 0 -and + ((Test-AuthenticationFailure ($result.Lines -join "`n")) -or + ($result.Lines -join "`n") -match '(?i)subscription .+doesn''t exist')) { + Connect-AzureCliSession + $result = Get-AzureCliAccountResult + } + Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'show') + $account = ($result.Lines -join "`n") | ConvertFrom-Json + if ($account.state -ne 'Enabled') { throw "Subscription '$($account.name)' is $($account.state), not Enabled." } + if ($TenantId -and $account.tenantId -ne $TenantId) { + throw 'The selected subscription does not belong to -TenantId. Select the intended subscription before provisioning.' + } + if ($account.user.type -ne 'user') { + throw 'This script requires a user Azure CLI login to grant the signed-in user Key Vault access. Service-principal and managed-identity provisioning are not supported.' + } + $script:AzureCliContext = $account + $script:GraphTenantId = $account.tenantId + + $arguments = @('account', 'set', '--subscription', $account.id) + $result = Invoke-AzCommand -Arguments $arguments + Assert-AzCommandSucceeded -Result $result -Arguments $arguments + foreach ($resourceName in @('Arm', 'Graph', 'KeyVault')) { + Ensure-AzureCliToken -ResourceName $resourceName + } + Assert-AzureCliTokens + Write-Host ' Azure auth : ARM, Microsoft Graph and Key Vault ready' +} + +function Invoke-AzResult { + param([string[]] $Arguments) + + # Directory commands use the tenant selected at initialization/sign-in, not --subscription. + if ($script:AzureCliContext -and $Arguments[0] -ne 'ad' -and $Arguments -notcontains '--subscription') { + $Arguments += @('--subscription', $script:AzureCliContext.id) + } + if ($Arguments -notcontains '--only-show-errors') { $Arguments += '--only-show-errors' } + $result = Invoke-AzCommand -Arguments $Arguments + $message = $result.Lines -join "`n" + if ($result.ExitCode -ne 0 -and $script:AzureCliContext -and (Test-AuthenticationFailure $message)) { + $resourceName = if ($message -match 'https://graph\.microsoft\.com') { + 'Graph' + } + elseif ($message -match 'https://management\.(core\.windows\.net|azure\.com)') { + 'Arm' + } + elseif ($message -match 'https://vault\.azure\.net') { + 'KeyVault' + } + elseif ($Arguments[0] -eq 'ad') { 'Graph' } + elseif ($Arguments[0] -eq 'keyvault' -and $Arguments[1] -in @('secret', 'key', 'certificate')) { + 'KeyVault' + } + else { 'Arm' } + + Write-Host " Azure auth : refreshing $resourceName authentication; retrying the command once" -ForegroundColor Yellow + Ensure-AzureCliToken -ResourceName $resourceName + Assert-AzureCliTokens + $result = Invoke-AzCommand -Arguments $Arguments + } + return $result +} + +function Invoke-Az { + # Keep this a simple function: advanced-function parameters collide with CLI flags such as -o. + $result = Invoke-AzResult -Arguments $args + Assert-AzCommandSucceeded -Result $result -Arguments $args + return $result.Lines +} + +function Ensure-AzRoleAssignment { + param([string] $ObjectId, [string] $PrincipalType, [string] $Role, [string] $Scope) + + $roleId = Invoke-Az role definition list --name $Role --query '[0].id' --output tsv + if ([string]::IsNullOrWhiteSpace($roleId)) { throw "Could not resolve role '$Role'." } + $nextPage = "https://management.azure.com${Scope}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" + do { + # Read ARM directly, avoiding directory lookups just to display principal names. + $page = ((Invoke-Az rest --method get --url $nextPage --output json) -join "`n") | ConvertFrom-Json + $existing = @($page.value | Where-Object { + $_.properties.principalId -eq $ObjectId -and $_.properties.scope -eq $Scope -and + $_.properties.roleDefinitionId.Split('/')[-1] -eq $roleId.Split('/')[-1] + }) + if ($existing.Count) { + Write-Host " $Role already present" -ForegroundColor DarkGray + return + } + $nextPage = if ($page.PSObject.Properties['nextLink']) { $page.nextLink } else { $null } + } while ($nextPage) + + Confirm-SetupAction -Action 'create role assignment' -Target "$Role -> $ObjectId" ` + -Details "Principal type: $PrincipalType; exact scope: $Scope." + $arguments = @('role', 'assignment', 'create', '--assignee-object-id', $ObjectId, + '--assignee-principal-type', $PrincipalType, '--role', $Role, '--scope', $Scope, + '--output', 'none', '--only-show-errors') + $result = Invoke-AzResult -Arguments $arguments + if ($result.ExitCode -ne 0 -and ($result.Lines -join "`n") -match '\bRoleAssignmentExists\b') { + Write-Host " $Role already present" -ForegroundColor DarkGray + return + } + Assert-AzCommandSucceeded -Result $result -Arguments $arguments +} + +function Ensure-FunctionTelemetry { + param([string] $FunctionName, [string] $Group, [string] $Region, [string] $Tag) + + $components = ((Invoke-Az resource list --resource-group $Group ` + --resource-type Microsoft.Insights/components --output json) -join "`n") | ConvertFrom-Json + if (@($components | Where-Object name -eq $FunctionName).Count) { return $FunctionName } + + $stem = $FunctionName + if ($stem.Length -gt 58) { $stem = $stem.Substring(0, 58) } + $workspaceName = "$stem-logs" + $workspaces = ((Invoke-Az monitor log-analytics workspace list --resource-group $Group --output json) -join "`n") | + ConvertFrom-Json + if (-not @($workspaces | Where-Object name -eq $workspaceName).Count) { + Confirm-SetupAction -Action 'create Log Analytics workspace' -Target $workspaceName ` + -Details "Resource group: $Group; location: $Region; PerGB2018, 30-day retention. Ingestion charges apply." + Invoke-Az monitor log-analytics workspace create --workspace-name $workspaceName ` + --resource-group $Group --location $Region --sku PerGB2018 --retention-time 30 --tags $Tag | Out-Null + } + $workspaceId = Invoke-Az monitor log-analytics workspace show --workspace-name $workspaceName ` + --resource-group $Group --query id --output tsv + if ([string]::IsNullOrWhiteSpace($workspaceId)) { throw 'The telemetry workspace has no resource ID.' } + + $actionGroups = ((Invoke-Az resource list --resource-group $Group ` + --resource-type Microsoft.Insights/actionGroups --output json) -join "`n") | ConvertFrom-Json + if (-not @($actionGroups | Where-Object name -eq 'Application Insights Smart Detection').Count) { + Confirm-SetupAction -Action 'allow creation of the standard telemetry action group' ` + -Target 'Application Insights Smart Detection' ` + -Details "Azure may create this supporting resource alongside Application Insights in $Group." + } + Confirm-SetupAction -Action 'create Application Insights component' -Target $FunctionName ` + -Details "Resource group: $Group; location: $Region; workspace: $workspaceName. Local-key authentication is disabled." + $propertiesFile = Join-Path ([IO.Path]::GetTempPath()) "epp-insights-$([Guid]::NewGuid()).json" + [IO.File]::WriteAllText($propertiesFile, (@{ + Application_Type = 'web' + WorkspaceResourceId = $workspaceId + DisableLocalAuth = $true + } | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) + try { + Invoke-Az resource create --resource-group $Group --name $FunctionName ` + --resource-type Microsoft.Insights/components --api-version 2020-02-02 --location $Region ` + --properties "@$propertiesFile" | Out-Null + } + finally { Remove-Item -LiteralPath $propertiesFile -Force -ErrorAction SilentlyContinue } + return $FunctionName +} + +function New-OrRecoverEndpointKeyVault { + param([string] $Name, [string] $Group, [string] $Region, [string] $Tag) + + $deletedVaults = ((Invoke-Az keyvault list-deleted --output json) -join "`n") | ConvertFrom-Json + $matchingVaults = @($deletedVaults | Where-Object name -eq $Name) + if ($matchingVaults.Count -gt 1) { throw "More than one deleted vault matches '$Name'; resolve this before continuing." } + if ($matchingVaults.Count -eq 1) { + $deleted = $matchingVaults[0] + $expectedId = "/subscriptions/$($script:AzureCliContext.id)/resourceGroups/$Group/providers/Microsoft.KeyVault/vaults/$Name" + if ($deleted.properties.vaultId -ne $expectedId) { + throw "Deleted vault '$Name' belongs to another resource group. Choose a different -KeyVaultName; it will not be recovered or purged automatically." + } + Confirm-SetupAction -Action 'recover soft-deleted Key Vault' -Target $Name ` + -Details "Original location: $($deleted.properties.location); resource group: $Group. Recovery retains its existing keys and secrets. No purge will be performed." + Invoke-Az keyvault recover --name $Name --resource-group $Group ` + --location $deleted.properties.location | Out-Null + Write-Host " Key Vault : $Name recovered" + } + else { + $Region = Read-SetupValue -Name Location -DefaultValue $Region -Required + Confirm-SetupAction -Action 'create Key Vault' -Target $Name ` + -Details "Resource group: $Group; location: $Region; Standard SKU with Azure RBAC." + Invoke-Az keyvault create --name $Name --resource-group $Group --location $Region ` + --enable-rbac-authorization true --sku standard --tags $Tag | Out-Null + Write-Host " Key Vault : $Name created" + } +} + +function Connect-EndpointGraph { + param([switch] $Reconnect, [string[]] $Scopes) + + if ($PSBoundParameters.ContainsKey('Scopes')) { + if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw 'Graph authentication requires at least one nonempty scope.' + } + $script:GraphRequiredScopes = $Scopes + } + + $context = Get-MgContext -ErrorAction Stop + $canReuse = $context -and $context.AuthType -eq 'Delegated' -and + $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and + $context.Environment -eq 'Global' -and + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and + (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) + + if ($Reconnect -or -not $canReuse) { + if ($NonInteractive) { + throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." + } + $connectParameters = @{ + Scopes = $script:GraphRequiredScopes + ContextScope = 'Process' + Environment = 'Global' + NoWelcome = $true + ErrorAction = 'Stop' + } + if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } + Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow + Connect-MgGraph @connectParameters | Out-Null + $context = Get-MgContext -ErrorAction Stop + } + + if (-not $context -or $context.AuthType -ne 'Delegated' -or + $context.Environment -ne 'Global' -or + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or + ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or + ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { + throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' + } + $script:GraphTenantId = $context.TenantId + $script:GraphAccountName = $context.Account +} + +function Invoke-EndpointGraph { + param([scriptblock] $Operation) + + try { + & $Operation + } + catch { + $exception = $_.Exception + $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) + while ($exception) { + if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or + ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or + ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { + $authenticationFailure = $true + } + $exception = $exception.InnerException + } + if (-not $authenticationFailure) { throw } + Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow + Connect-EndpointGraph -Reconnect + & $Operation + } +} + +function Get-CyotApplication { + param([string] $ApplicationId, [switch] $RequireMultiTenant) + + $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid + $matches = @(Invoke-EndpointGraph { + Get-MgApplication -Filter "appId eq '$ApplicationId'" -Property Id, AppId -All -ErrorAction Stop + }) + if ($matches.Count -ne 1) { + throw "Expected exactly one app registration with client ID '$ApplicationId' in tenant '$script:GraphTenantId'; found $($matches.Count). Complete app registration in the correct tenant. No replacement app will be created." + } + $application = Invoke-EndpointGraph { + Get-MgApplication -ApplicationId $matches[0].Id ` + -Property Id, AppId, DisplayName, SignInAudience, Api, IdentifierUris, KeyCredentials, TokenEncryptionKeyId ` + -ErrorAction Stop + } + if (-not $application -or $application.AppId -ne $ApplicationId) { + throw 'Graph did not return the requested application.' + } + if ($RequireMultiTenant -and $application.SignInAudience -ne 'AzureADMultipleOrgs') { + throw "Application '$ApplicationId' is not multi-tenant. Review and approve that change in the app-registration stage before provisioning." + } + return $application +} + +function Ensure-CyotEndpointServicePrincipal { + param([string] $ApplicationId) + + $principals = @(Invoke-EndpointGraph { + Get-MgServicePrincipal -Filter "appId eq '$ApplicationId'" -All -ErrorAction Stop + }) + if ($principals.Count -gt 1) { throw "Multiple service principals match application '$ApplicationId'." } + if (-not $principals.Count) { + Confirm-SetupAction -Action 'create endpoint service principal' -Target $ApplicationId ` + -Details "Tenant: $script:GraphTenantId. No permission is granted to the provider or Microsoft." + $principal = Invoke-EndpointGraph { New-MgServicePrincipal -AppId $ApplicationId -ErrorAction Stop } + } + else { $principal = $principals[0] } + if (-not $principal -or -not $principal.Id) { throw 'Graph did not return an endpoint service-principal ID.' } + if ($principal.AppRoleAssignmentRequired) { + Confirm-SetupAction -Action 'remove endpoint app-role assignment requirement' -Target $principal.Id ` + -Details 'Microsoft EPP uses its pre-authorized caller identity. Easy Auth must still restrict callers to the Microsoft EPP application.' + Invoke-EndpointGraph { + Update-MgServicePrincipal -ServicePrincipalId $principal.Id -AppRoleAssignmentRequired:$false -ErrorAction Stop + } | Out-Null + } + return $principal +} + +function Get-ProviderEntraSettings { + param([string] $ProviderTenantId, [string] $ProviderScope) + + $ProviderTenantId = Read-SetupValue -Name ProviderTenantId -DefaultValue $ProviderTenantId -Required -ValueType Guid + $ProviderScope = Read-SetupValue -Name ProviderScope -DefaultValue $ProviderScope -Required -ValueType Scope + return @{ + EPP_PROVIDER_AUTH_MODE = 'ests' + EPP_PROVIDER_TENANT_ID = $ProviderTenantId + EPP_PROVIDER_SCOPE = $ProviderScope + } +} + +function Ensure-CyotProviderIdentity { + param( + [string] $FunctionName, [string] $Group, [string] $Region, [string] $Tag, + [string] $IdentityName, $Application + ) + + if ($script:AzureCliContext.tenantId -ne $script:GraphTenantId -or + $Application.SignInAudience -ne 'AzureADMultipleOrgs') { + throw 'Provider federation requires a multi-tenant application and managed identity in the same customer tenant.' + } + if ([string]::IsNullOrWhiteSpace($IdentityName)) { $IdentityName = "$FunctionName-outbound" } + if ($IdentityName -notmatch '^[a-zA-Z0-9_-]{3,128}$') { + throw '-OutboundIdentityName must be 3-128 letters, digits, underscores or hyphens.' + } + + $identities = ((Invoke-Az identity list --resource-group $Group --output json) -join "`n") | ConvertFrom-Json + $matches = @($identities | Where-Object name -eq $IdentityName) + if ($matches.Count -gt 1) { throw "Multiple managed identities match '$IdentityName'." } + if (-not $matches.Count) { + $Region = Read-SetupValue -Name Location -DefaultValue $Region -Required + Confirm-SetupAction -Action 'create outbound user-assigned managed identity' -Target $IdentityName ` + -Details "Resource group: $Group; location: $Region. This identity will authenticate as application $($Application.AppId) to the provider." + Invoke-Az identity create --name $IdentityName --resource-group $Group --location $Region --tags $Tag | Out-Null + } + $identity = ((Invoke-Az identity show --name $IdentityName --resource-group $Group --output json) -join "`n") | + ConvertFrom-Json + if (-not $identity.id -or -not $identity.clientId -or -not $identity.principalId -or + $identity.tenantId -ne $script:GraphTenantId) { + throw 'The outbound managed identity is incomplete or belongs to another tenant.' + } + + $functionIdentity = ((Invoke-Az functionapp identity show --name $FunctionName --resource-group $Group --output json) -join "`n") | + ConvertFrom-Json + $userIdentities = @() + if ($functionIdentity.PSObject.Properties['userAssignedIdentities'] -and $functionIdentity.userAssignedIdentities) { + $userIdentities = @($functionIdentity.userAssignedIdentities.PSObject.Properties.Name) + } + if ($userIdentities -notcontains $identity.id) { + Confirm-SetupAction -Action 'attach outbound managed identity to Function App' -Target $FunctionName ` + -Details "Attach $($identity.id). Retain the system-assigned identity and all existing user-assigned identities." + $identityIds = @('[system]') + $userIdentities + @($identity.id) + Invoke-Az functionapp identity assign --name $FunctionName --resource-group $Group --identities @identityIds | Out-Null + } + + $issuer = "https://login.microsoftonline.com/$script:GraphTenantId/v2.0" + $audience = 'api://AzureADTokenExchange' + $credentialName = "cyot-$FunctionName-outbound" + $credentials = @(Invoke-EndpointGraph { + Get-MgApplicationFederatedIdentityCredential -ApplicationId $Application.Id -All -ErrorAction Stop + }) + $matchingCredentials = @($credentials | Where-Object { + $_.Issuer -ceq $issuer -and $_.Subject -ceq $identity.principalId -and + @($_.Audiences).Count -eq 1 -and $_.Audiences[0] -ceq $audience + }) + if (-not $matchingCredentials.Count) { + if (@($credentials | Where-Object Name -eq $credentialName).Count) { + throw "Federated credential '$credentialName' already exists with a different trust relationship. It will not be overwritten." + } + Confirm-SetupAction -Action 'create application federated identity credential' -Target "$($Application.AppId)/$credentialName" ` + -Details "Trust managed-identity principal $($identity.principalId), issuer $issuer, audience $audience. No client secret is created." + Invoke-EndpointGraph { + New-MgApplicationFederatedIdentityCredential -ApplicationId $Application.Id -BodyParameter @{ + Name = $credentialName + Issuer = $issuer + Subject = $identity.principalId + Audiences = @($audience) + } -ErrorAction Stop + } | Out-Null + } + return @{ + EPP_OUTBOUND_CLIENT_ID = $Application.AppId + EPP_OUTBOUND_MI_CLIENT_ID = $identity.clientId + } +} + +$stageResult = $null +$stageSucceeded = $false + +try { +Initialize-SetupLogging +$guidedEndpointSelection = [string]::IsNullOrWhiteSpace($FunctionAppName) -and [string]::IsNullOrWhiteSpace($EndpointUrl) +$endpointSelection = Resolve-EndpointParameters -FunctionName $FunctionAppName -ExistingEndpoint $EndpointUrl +if ($endpointSelection.ExitRequested) { + Write-Host 'Stage 2 exited without making changes.' -ForegroundColor Yellow + $stageSucceeded = $true + return +} +$FunctionAppName = $endpointSelection.FunctionAppName +$EndpointUrl = $endpointSelection.EndpointUrl +$provisionFunction = $endpointSelection.ProvisionFunction +if ($guidedEndpointSelection -and -not $NonInteractive -and -not $PSBoundParameters.ContainsKey('StartFromStep')) { + $StartFromStep = Show-ResumeSelectionMenu +} +Write-SetupEvent -Level INFO -Message "Starting Stage 2 from step $StartFromStep. Earlier prerequisites will be verified and reconstructed." + +# --------------------------------------------------------------------------- +# 1. Provision the Azure Function +# --------------------------------------------------------------------------- +# The app already exists from stage 1; only its hostname-based identifier URI must wait for the host. +if ($provisionFunction -and $StartFromStep -le 1) { + Write-Step 'Provisioning the Azure Function' + + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required to provision the Function. Install it, or pass -EndpointUrl to skip provisioning.' + } + + Initialize-AzureCliAuthentication + $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid + Connect-EndpointGraph -Scopes @('Application.ReadWrite.All') + $application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant + $subscriptionName = $script:AzureCliContext.name + $resolvedSubscriptionId = $script:AzureCliContext.id + Write-Host " Subscription : $subscriptionName" + + $ResourceGroup = Read-SetupValue -Name ResourceGroup -DefaultValue $ResourceGroup -Required + $ResourceTagName = Read-SetupValue -Name ResourceTagName -DefaultValue $ResourceTagName -Required + $ResourceTagValue = Read-SetupValue -Name ResourceTagValue -DefaultValue $ResourceTagValue -Required + $resourceTag = "$ResourceTagName=$ResourceTagValue" + + $groupExists = Invoke-Az group exists --name $ResourceGroup --output tsv + if ($groupExists -eq 'false') { + $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required + Confirm-SetupAction -Action 'create resource group' -Target $ResourceGroup -Details "Location: $Location." + Invoke-Az group create --name $ResourceGroup --location $Location --tags $resourceTag | Out-Null + } + elseif ($groupExists -ne 'true') { throw "Unexpected resource-group existence response: $groupExists" } + Write-Host " Resource group: $ResourceGroup" + + # Derive optional resource names without prompting; only validate them when needed. + if ([string]::IsNullOrWhiteSpace($StorageAccountName)) { + $StorageAccountName = Get-DefaultStorageAccountName $FunctionAppName + } + $StorageAccountName = Read-SetupValue -Name StorageAccountName -DefaultValue $StorageAccountName -Required -ValueType StorageName + $storageExists = (Invoke-Az storage account list --resource-group $ResourceGroup --query "[?name=='$StorageAccountName'] | length(@)" -o tsv) + if ($storageExists -eq '0') { + $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required + Confirm-SetupAction -Action 'create storage account' -Target $StorageAccountName ` + -Details "Resource group: $ResourceGroup; location: $Location; SKU: Standard_LRS. Storage charges apply." + Invoke-Az storage account create ` + --name $StorageAccountName ` + --resource-group $ResourceGroup ` + --location $Location ` + --sku Standard_LRS ` + --min-tls-version TLS1_2 ` + --allow-blob-public-access false ` + --tags $resourceTag | Out-Null + Write-Host " Storage : $StorageAccountName created" + } + else { + Write-Host " Storage : $StorageAccountName exists" + } + + $functionExists = (Invoke-Az functionapp list --resource-group $ResourceGroup --query "[?name=='$FunctionAppName'] | length(@)" -o tsv) + + if ($functionExists -eq '0') { + $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required + $PlanType = Read-SetupValue -Name PlanType -DefaultValue $PlanType -Required ` + -ValueType Choice -Choices @('FlexConsumption', 'Premium') + # Create telemetry explicitly so no workspace appears silently in a different resource group. + $insightsName = Ensure-FunctionTelemetry -FunctionName $FunctionAppName ` + -Group $ResourceGroup -Region $Location -Tag $resourceTag + if ($PlanType -eq 'FlexConsumption') { + # Flex Consumption supports always-ready instances, which is the only way a consumption + # style plan stays inside the 3.2 s budget. Requires Azure CLI 2.61 or later. + # Node 24 to match the reference package. Node 20 is out of support and Node 22, though + # still the platform default, reaches end of life in April 2027. + Confirm-SetupAction -Action 'create Flex Consumption hosting plan' -Target "$FunctionAppName (CLI-assigned plan name)" ` + -Details "Resource group: $ResourceGroup; location: $Location. The CLI creates this together with the Function. One always-ready instance incurs charges." + Confirm-SetupAction -Action 'create deployment storage container' -Target "$StorageAccountName (CLI-managed container)" ` + -Details 'The Function creation command creates or reuses its deployment container in this storage account.' + Confirm-SetupAction -Action 'create Function App' -Target $FunctionAppName ` + -Details "Resource group: $ResourceGroup; location: $Location; Node 24, Flex Consumption." + Invoke-Az functionapp create ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --storage-account $StorageAccountName ` + --app-insights $insightsName ` + --flexconsumption-location $Location ` + --runtime node ` + --runtime-version 24 ` + --instance-memory 2048 | Out-Null + + # Without this the first request after an idle period pays a cold start and times out. + Invoke-Az functionapp scale config always-ready set ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --settings http=1 | Out-Null + + Write-Host " Plan : Flex Consumption, 1 always-ready instance" + } + else { + $planName = "$FunctionAppName-plan" + $plans = ((Invoke-Az functionapp plan list --resource-group $ResourceGroup --output json) -join "`n") | ConvertFrom-Json + if (-not @($plans | Where-Object name -eq $planName).Count) { + Confirm-SetupAction -Action 'create Premium hosting plan' -Target $planName ` + -Details "Resource group: $ResourceGroup; location: $Location; Linux EP1. Ongoing charges apply." + Invoke-Az functionapp plan create ` + --name $planName ` + --resource-group $ResourceGroup ` + --location $Location ` + --sku EP1 ` + --is-linux true | Out-Null + } + Confirm-SetupAction -Action 'create Function runtime storage' -Target "$StorageAccountName (Function-managed content storage)" ` + -Details 'Azure creates or reuses its content share and runtime containers during Function creation.' + Confirm-SetupAction -Action 'create Function App' -Target $FunctionAppName ` + -Details "Resource group: $ResourceGroup; Linux Node 24, Premium plan: $planName; storage: $StorageAccountName." + Invoke-Az functionapp create ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --storage-account $StorageAccountName ` + --app-insights $insightsName ` + --plan $planName ` + --runtime node ` + --runtime-version 24 ` + --functions-version 4 | Out-Null + + Write-Host " Plan : Premium EP1, always warm" + } + + Write-Host " Function app : $FunctionAppName created" + } + else { + Write-Host " Function app : $FunctionAppName exists" + } + + # Microsoft rejects any endpoint that is not HTTPS, so leaving the HTTP listener open only + # invites a delivery that never happens. + Invoke-Az functionapp update ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --set httpsOnly=true | Out-Null + + Invoke-Az functionapp config set ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --min-tls-version 1.2 | Out-Null + + # A managed identity is how the Function reaches Key Vault, and how the platform reaches storage + # once the account keys below are removed. + $principalId = Invoke-Az functionapp identity show --name $FunctionAppName ` + --resource-group $ResourceGroup --query principalId --output tsv + if ([string]::IsNullOrWhiteSpace($principalId)) { + Confirm-SetupAction -Action 'create system-assigned managed identity' -Target $FunctionAppName ` + -Details "This creates the Function's service principal in tenant $($script:AzureCliContext.tenantId)." + $principalId = (Invoke-Az functionapp identity assign ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --query principalId -o tsv) + } + + # --- Storage: managed identity instead of account keys -------------------------------------- + # A new function app is created with the storage account key embedded in AzureWebJobsStorage + # and, on Flex Consumption, again in a second setting for the deployment container. Both are + # replaced here so no account key is left in configuration for anyone to read or leak. + $storageId = (Invoke-Az storage account show ` + --name $StorageAccountName ` + --resource-group $ResourceGroup ` + --query id -o tsv) + + foreach ($role in @( + 'Storage Blob Data Contributor', + 'Storage Queue Data Contributor', + 'Storage Table Data Contributor')) { + + Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` + -Role $role -Scope $storageId + } + + Write-Host ' Storage roles : blob, queue and table data contributor' + + # Role assignments take time to reach the data plane. Switching over immediately produces an + # authorization failure on the next deployment that reads like a corrupt package. + Start-Sleep -Seconds 30 + + Invoke-Az functionapp deployment config set ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --deployment-storage-auth-type SystemAssignedIdentity | Out-Null + + Invoke-Az functionapp config appsettings set ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --settings "AzureWebJobsStorage__accountName=$StorageAccountName" | Out-Null + + # Removed last. Deleting the connection strings before the identity path is in place would + # strand the host with no way to reach its own storage. + Invoke-Az functionapp config appsettings delete ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --setting-names AzureWebJobsStorage DEPLOYMENT_STORAGE_CONNECTION_STRING ` + -o none --only-show-errors | Out-Null + + Write-Host ' Storage auth : managed identity, no account key in configuration' + + # --- Key Vault for the encryption private key ------------------------------------------------ + # The private key is the one secret that matters: it is the only thing that can open a passcode. + # It goes in a vault and reaches the Function as a reference, so it never appears in app settings + # where anyone with Reader on the site could read it. + if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { + # 3-24 characters, alphanumerics and hyphens, globally unique. + $KeyVaultName = Get-DefaultKeyVaultName $FunctionAppName + } + $KeyVaultName = Read-SetupValue -Name KeyVaultName -DefaultValue $KeyVaultName -Required -ValueType VaultName + + $vaultExists = (Invoke-Az keyvault list --resource-group $ResourceGroup ` + --query "[?name=='$KeyVaultName'] | length(@)" -o tsv) + + if ($vaultExists -eq '0') { + New-OrRecoverEndpointKeyVault -Name $KeyVaultName -Group $ResourceGroup -Region $Location -Tag $resourceTag + } + else { + Write-Host " Key Vault : $KeyVaultName exists" + } + + $vaultId = (Invoke-Az keyvault show --name $KeyVaultName --resource-group $ResourceGroup --query id -o tsv) + + # The Function reads the secret; whoever runs this script writes it. + Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` + -Role 'Key Vault Secrets User' -Scope $vaultId + + $callerObjectId = (Invoke-Az ad signed-in-user show --query id -o tsv) + Ensure-AzRoleAssignment -ObjectId $callerObjectId -PrincipalType User ` + -Role 'Key Vault Secrets Officer' -Scope $vaultId + + Write-Host ' Key Vault RBAC: function reads secrets, you write them' + + # Read from ARM rather than 'az functionapp show'. On Flex Consumption that command returns null + # for defaultHostName, state and hostNames while still exiting 0, so the hostname silently comes + # back empty and the failure only shows up later as an unparseable URI. + $defaultHostName = (Invoke-Az resource show ` + --resource-group $ResourceGroup ` + --name $FunctionAppName ` + --resource-type Microsoft.Web/sites ` + --query properties.defaultHostName -o tsv) + + if ([string]::IsNullOrWhiteSpace($defaultHostName)) { + throw "Could not read the hostname for '$FunctionAppName'. The app may still be provisioning." + } + + $FunctionRoute = Read-SetupValue -Name FunctionRoute -DefaultValue $FunctionRoute -Required + $EndpointUrl = "https://$defaultHostName/$($FunctionRoute.TrimStart('/'))" + + Write-Host " Identity : $principalId" + Write-Host " Endpoint URL : $EndpointUrl" +} +elseif ($provisionFunction) { + Write-Step "Reconstructing Azure state before resumed step $StartFromStep" + + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required to resume Function configuration.' + } + Initialize-AzureCliAuthentication + $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid + $resolvedSubscriptionId = $script:AzureCliContext.id + $ResourceGroup = Read-SetupValue -Name ResourceGroup -DefaultValue $ResourceGroup -Required + $ResourceTagName = Read-SetupValue -Name ResourceTagName -DefaultValue $ResourceTagName -Required + $ResourceTagValue = Read-SetupValue -Name ResourceTagValue -DefaultValue $ResourceTagValue -Required + $resourceTag = "$ResourceTagName=$ResourceTagValue" + + $groupExists = Invoke-Az group exists --name $ResourceGroup --output tsv + if ($groupExists -ne 'true') { + throw "Resource group '$ResourceGroup' is missing. Resume with -StartFromStep 1." + } + $functionExists = Invoke-Az functionapp list --resource-group $ResourceGroup --query "[?name=='$FunctionAppName'] | length(@)" -o tsv + if ($functionExists -eq '0') { + throw "Function app '$FunctionAppName' is missing. Resume with -StartFromStep 1." + } + + $functionResource = ((Invoke-Az resource show --resource-group $ResourceGroup --name $FunctionAppName ` + --resource-type Microsoft.Web/sites --output json) -join "`n") | ConvertFrom-Json + $defaultHostName = $functionResource.properties.defaultHostName + $Location = $functionResource.location + if ([string]::IsNullOrWhiteSpace($defaultHostName)) { + throw "Could not reconstruct the hostname for '$FunctionAppName'. Resume with -StartFromStep 1." + } + + $principalId = Invoke-Az functionapp identity show --name $FunctionAppName ` + --resource-group $ResourceGroup --query principalId --output tsv + if ([string]::IsNullOrWhiteSpace($principalId)) { + throw "Function app '$FunctionAppName' has no system-assigned identity. Resume with -StartFromStep 1." + } + + if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { $KeyVaultName = Get-DefaultKeyVaultName $FunctionAppName } + $KeyVaultName = Read-SetupValue -Name KeyVaultName -DefaultValue $KeyVaultName -Required -ValueType VaultName + $vaultExists = Invoke-Az keyvault list --resource-group $ResourceGroup ` + --query "[?name=='$KeyVaultName'] | length(@)" -o tsv + if ($vaultExists -eq '0') { + throw "Key Vault '$KeyVaultName' is missing. Resume with -StartFromStep 1." + } + + $FunctionRoute = Read-SetupValue -Name FunctionRoute -DefaultValue $FunctionRoute -Required + $EndpointUrl = "https://$defaultHostName/$($FunctionRoute.TrimStart('/'))" + Write-Host " Function app : $FunctionAppName" + Write-Host " Endpoint URL : $EndpointUrl" + Write-Host " Key Vault : $KeyVaultName" +} + +# --------------------------------------------------------------------------- +# 2. Validate the endpoint URL against the rules Microsoft enforces per delivery +# --------------------------------------------------------------------------- +Write-Step 'Validating the endpoint URL' + +$uri = [System.Uri]::new($EndpointUrl) + +if ($uri.Scheme -ne 'https') { + throw "The endpoint must use HTTPS. Got '$($uri.Scheme)'." +} +if ($uri.IsLoopback) { + throw 'The endpoint must not be a loopback address. Microsoft rejects these before sending.' +} +if ($uri.HostNameType -in @('IPv4', 'IPv6')) { + throw 'The endpoint must use a hostname, not a literal IP address.' +} + +$endpointHost = $uri.Host +Write-Host " Endpoint host : $endpointHost" + +# --------------------------------------------------------------------------- +# 3. Read the existing stage-1 application +# --------------------------------------------------------------------------- +Write-Step 'Connecting to Microsoft Graph' + +# The SDK owns its own refreshable credentials; an Azure CLI access token is not interchangeable. +Connect-EndpointGraph +$ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid +$application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant +$appId = $application.AppId +$graphContext = Get-MgContext -ErrorAction Stop +$tenantId = $script:GraphTenantId +Write-Host " Graph account : $($graphContext.Account)" +Write-Host " Tenant : $tenantId" + +# --------------------------------------------------------------------------- +# 4. Encryption certificate +# --------------------------------------------------------------------------- +Write-Step 'Preparing the encryption certificate' + +$CertificatePath = Read-SetupValue -Name CertificatePath -DefaultValue $CertificatePath -ValueType File +if ($CertificatePath) { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath) + Write-Host " Using : $CertificatePath" +} +else { + # Reuse a certificate this script created earlier for the same host, if one is still valid. + # Minting a fresh certificate on every run leaves a trail of key credentials on the application + # and orphaned private keys in the store, which makes "safe to re-run" untrue in the one place + # it matters most. + $subject = "CN=$endpointHost External Phone Provider Encryption" + $certificate = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $subject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date).AddDays(30) } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + + if ($certificate) { + Write-Host " Reusing : $($certificate.Thumbprint) (expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))" + } + elseif ($StartFromStep -gt 4) { + throw "No reusable encryption certificate was found for '$endpointHost'. Resume with -StartFromStep 4 or supply -CertificatePath." + } + else { + # RSA 2048 is the minimum Microsoft accepts. Keep the private key safe: it is the only thing + # that can open a passcode, and Microsoft never has a copy. + Confirm-SetupAction -Action 'create encryption certificate' -Target $subject ` + -Details "RSA 2048, one-year validity, CurrentUser\My. The public certificate will be exported alongside this script." + $certificate = New-SelfSignedCertificate ` + -Subject $subject ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -KeyExportPolicy Exportable ` + -KeyUsage KeyEncipherment, DataEncipherment ` + -NotAfter (Get-Date).AddYears(1) + + $exportPath = Join-Path $PSScriptRoot "phone-provider-encryption-$endpointHost.cer" + Export-Certificate -Cert $certificate -FilePath $exportPath -Force | Out-Null + + Write-Host " Created : $($certificate.Thumbprint)" + Write-Host " Public copy : $exportPath" + } +} + +if ($certificate.PublicKey.Key.KeySize -lt 2048) { + throw "The encryption key must be at least 2048 bits. Got $($certificate.PublicKey.Key.KeySize)." +} + +# --------------------------------------------------------------------------- +# 5. Reuse the stage-1 registration +# --------------------------------------------------------------------------- +Write-Step 'Configuring the application from stage 1' +Write-Host " Reusing : $appId ($($application.DisplayName))" + +# --------------------------------------------------------------------------- +# 6. Identifier URI - binds the application to the endpoint host +# --------------------------------------------------------------------------- +Write-Step 'Publishing the identifier URI' + +# Host only. No port, no path. Microsoft builds this same string and asks Entra for a token against +# it, so a mismatch means no token is ever issued and nothing is delivered. +$identifierUri = "api://$endpointHost/$appId" + +$existingUris = @($application.IdentifierUris) +if ($existingUris -notcontains $identifierUri) { + if ($StartFromStep -gt 6) { + throw "Identifier URI '$identifierUri' is missing. Resume with -StartFromStep 6." + } + Invoke-EndpointGraph { + Update-MgApplication -ApplicationId $application.Id -IdentifierUris (@($existingUris) + $identifierUri) -ErrorAction Stop + } +} + +Write-Host " Identifier URI: $identifierUri" + +# --------------------------------------------------------------------------- +# 7. Key credential with usage Encrypt +# --------------------------------------------------------------------------- +Write-Step 'Publishing the encryption key' + +# usage must be 'Encrypt'. A signing credential is not interchangeable, and Microsoft filters on this. +# +# An existing credential for this same certificate is reused. Publishing a second credential for a +# certificate the application already carries leaves stale keys accumulating on the registration, +# and every one of them is a key someone could later be confused by. +$certHash = $certificate.GetCertHash() +$existingCredential = @($application.KeyCredentials) | + Where-Object { $_.CustomKeyIdentifier -and (-not (Compare-Object $_.CustomKeyIdentifier $certHash)) } | + Select-Object -First 1 + +if ($existingCredential) { + $keyId = $existingCredential.KeyId + Write-Host " Key id : $keyId (already published)" +} +else { + if ($StartFromStep -gt 7) { + throw "The encryption certificate is not published on the application. Resume with -StartFromStep 7." + } + $keyId = [Guid]::NewGuid().ToString() + Confirm-SetupAction -Action 'publish new encryption key credential' -Target "$appId / $keyId" ` + -Details "Tenant: $tenantId; certificate: $($certificate.Thumbprint). Only the public key is published." + + $keyCredential = @{ + CustomKeyIdentifier = $certHash + DisplayName = "external phone provider encryption $($certificate.Thumbprint)" + Key = $certificate.GetRawCertData() + KeyId = $keyId + Type = 'AsymmetricX509Cert' + Usage = 'Encrypt' + StartDateTime = $certificate.NotBefore.ToUniversalTime() + EndDateTime = $certificate.NotAfter.ToUniversalTime() + } + + $currentKeys = @($application.KeyCredentials | Where-Object { $_.KeyId -ne $keyId }) + + Invoke-EndpointGraph { + Update-MgApplication -ApplicationId $application.Id ` + -KeyCredentials (@($currentKeys) + $keyCredential) ` + -TokenEncryptionKeyId $keyId -ErrorAction Stop + } + + Write-Host " Key id : $keyId" +} + +# Nominated every time: on a reused credential this is a no-op, and on a rotation it is the step +# that actually points Microsoft at the new key. +if ($StartFromStep -gt 7 -and $application.TokenEncryptionKeyId -ne $keyId) { + throw "The published key is not nominated as tokenEncryptionKeyId. Resume with -StartFromStep 7." +} +if ($StartFromStep -le 7) { + Invoke-EndpointGraph { + Update-MgApplication -ApplicationId $application.Id -TokenEncryptionKeyId $keyId -ErrorAction Stop + } +} +Write-Host ' Nominated as tokenEncryptionKeyId' + +# --------------------------------------------------------------------------- +# 8. Service principals +# --------------------------------------------------------------------------- +Write-Step 'Creating service principals' + +$endpointSp = if ($StartFromStep -le 8) { + Ensure-CyotEndpointServicePrincipal -ApplicationId $appId +} +else { + Invoke-EndpointGraph { Get-MgServicePrincipal -Filter "appId eq '$appId'" -ErrorAction Stop } | Select-Object -First 1 +} +if (-not $endpointSp) { throw "The endpoint service principal is missing. Resume with -StartFromStep 8." } +Write-Host " Endpoint SP : $($endpointSp.Id)" + +# Microsoft's application is normally provisioned on first use. Creating it now turns a first-call +# failure into a setup-time one, which is easier to diagnose. Nothing is granted to it. +$microsoftSp = Invoke-EndpointGraph { + Get-MgServicePrincipal -Filter "appId eq '$MicrosoftPhoneProviderAppId'" -ErrorAction Stop +} | + Select-Object -First 1 + +if (-not $microsoftSp) { + if ($StartFromStep -gt 8) { + throw "The Microsoft phone-provider service principal is missing. Resume with -StartFromStep 8." + } + Confirm-SetupAction -Action 'create Microsoft service principal in this tenant' -Target $MicrosoftPhoneProviderAppId ` + -Details "Tenant: $tenantId. No application permissions are granted by this action." + $microsoftSp = Invoke-EndpointGraph { + New-MgServicePrincipal -AppId $MicrosoftPhoneProviderAppId -ErrorAction Stop + } + Write-Host ' Microsoft SP : created' +} +else { + Write-Host ' Microsoft SP : exists' +} + +# --------------------------------------------------------------------------- +# 9. Secure the Function, tell it what to accept, and deploy the code +# --------------------------------------------------------------------------- +# Deferred to here because none of it can be known until the application exists: the audience is +# built from the hostname and the application id, so the Function has to be created bare in step 1 +# and secured on a second pass once the registration is in place. +if ($provisionFunction -and $StartFromStep -le 9) { + Write-Step 'Storing the private key in Key Vault' + + # PKCS#8 is the form crypto.createPrivateKey and most libraries read without coaxing. Base64 on + # top of it so the PEM's newlines survive being carried as a secret value and then as an + # environment variable. + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate) + if (-not $rsa) { throw 'The certificate carries no RSA private key.' } + + $pemBuilder = [System.Text.StringBuilder]::new() + [void]$pemBuilder.AppendLine('-----BEGIN PRIVATE KEY-----') + [void]$pemBuilder.AppendLine([Convert]::ToBase64String($rsa.ExportPkcs8PrivateKey(), [Base64FormattingOptions]::InsertLineBreaks)) + [void]$pemBuilder.AppendLine('-----END PRIVATE KEY-----') + + $secretValue = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pemBuilder.ToString())) + $secretName = 'phone-provider-decryption-key' + Confirm-SetupAction -Action 'create a Key Vault secret version' -Target "$KeyVaultName/$secretName" ` + -Details 'Stores the encryption private key. An existing secret will receive a new version; its value is never displayed.' + + # Role assignments on a new vault take time to reach the data plane, and the first write is what + # discovers that. Retried rather than failed, because the alternative is a script that works only + # on the second run. + $secretId = $null + $secretArguments = @('keyvault', 'secret', 'set', + '--vault-name', $KeyVaultName, '--name', $secretName, '--value', $secretValue, + '--query', 'id', '--output', 'tsv', '--only-show-errors') + foreach ($attempt in 1..6) { + $secretResult = Invoke-AzResult -Arguments $secretArguments + if ($secretResult.ExitCode -eq 0) { + $secretId = ($secretResult.Lines -join '').Trim() + if (-not $secretId) { throw 'Key Vault returned success without a secret ID.' } + break + } + + if ($attempt -eq 6 -or ($secretResult.Lines -join "`n") -notmatch + 'ForbiddenByRbac|Caller is not authorized to perform action on resource') { + Assert-AzCommandSucceeded -Result $secretResult -Arguments $secretArguments + } + Write-Host " Key Vault RBAC: waiting for the secret-write permission (attempt $attempt/6)" -ForegroundColor DarkGray + Start-Sleep -Seconds 15 + } + + # Versionless, so rotating the key does not require touching the app setting. + $secretUri = ($secretId -replace '/[^/]+$', '') + Write-Host " Secret : $secretName" + Write-Host " Reference : $secretUri" + + Write-Step 'Configuring and deploying the Function' + + # A reused application may have been created in the portal, which pins v2. Read what is actually + # there rather than assuming, because the token version decides both aud and iss, and a validator + # configured for the wrong one rejects every delivery. + $tokenVersion = 1 + if ($application.PSObject.Properties.Name -contains 'Api' -and $application.Api -and + $application.Api.RequestedAccessTokenVersion) { + $tokenVersion = [int]$application.Api.RequestedAccessTokenVersion + } + + if ($tokenVersion -eq 2) { + $issuer = "https://login.microsoftonline.com/$tenantId/v2.0" + $expectedAudience = $appId + } + else { + $issuer = "https://sts.windows.net/$tenantId/" + $expectedAudience = $identifierUri + } + + Write-Host " Token version : v$tokenVersion" + Write-Host " Expected aud : $expectedAudience" + + # Everything the Function needs, in one write. The provider values reach here from three + # different places -- the selection, the security store and the customer -- but they are all + # ordinary app settings by the time the Function reads them. + # + # The key is the exception: it is a Key Vault reference the platform resolves with the managed + # identity, so the private key itself is never stored here. + $appSettings = @{ + EPP_EXPECTED_AUDIENCE = $expectedAudience + EPP_EXPECTED_ISSUER = $issuer + EPP_EXPECTED_CLIENT_ID = $MicrosoftPhoneProviderAppId + EPP_TENANT_ID = $tenantId + EPP_ENCRYPTION_KEY_ID = $keyId + EPP_DECRYPTION_KEY_PEM = "@Microsoft.KeyVault(SecretUri=$secretUri)" + } + + # An omitted int parameter defaults to 0 in PowerShell; distinguish it from a supplied 0. + $providerSettings = Get-ProviderAppSettings -Name $ProviderName -Endpoint $ProviderEndpoint ` + -TimeoutMs $(if ($PSBoundParameters.ContainsKey('ProviderTimeoutMs')) { $ProviderTimeoutMs } else { $null }) ` + -RetryIntervalMs $(if ($PSBoundParameters.ContainsKey('ProviderRetryIntervalMs')) { $ProviderRetryIntervalMs } else { $null }) ` + -AccountName $ProviderAccountName + foreach ($setting in $providerSettings.Keys) { $appSettings[$setting] = $providerSettings[$setting] } + $providerEntraSettings = Get-ProviderEntraSettings -ProviderTenantId $ProviderTenantId -ProviderScope $ProviderScope + $outboundSettings = Ensure-CyotProviderIdentity -FunctionName $FunctionAppName -Group $ResourceGroup ` + -Region $Location -Tag $resourceTag -IdentityName $OutboundIdentityName -Application $application + foreach ($setting in $providerEntraSettings.Keys) { $appSettings[$setting] = $providerEntraSettings[$setting] } + foreach ($setting in $outboundSettings.Keys) { $appSettings[$setting] = $outboundSettings[$setting] } + Write-Host ' Provider auth : Entra token exchange via a user-assigned managed identity; no client secret' + Write-Host ' The deployed package must read EPP_OUTBOUND_MI_CLIENT_ID explicitly; do not set AZURE_CLIENT_ID globally.' -ForegroundColor Yellow + + # --- Application Insights without a usable ingestion key ------------------------------------- + # The connection string cannot be removed -- it carries the ingestion endpoints -- but the + # instrumentation key inside it stops being a credential once local auth is off and telemetry + # has to be published with an Entra token. + $insightsArguments = @('resource', 'show', '--resource-group', $ResourceGroup, '--name', $FunctionAppName, + '--resource-type', 'Microsoft.Insights/components', '--query', 'id', '--output', 'tsv', '--only-show-errors') + $insightsResult = Invoke-AzResult -Arguments $insightsArguments + if ($insightsResult.ExitCode -ne 0 -and ($insightsResult.Lines -join "`n") -notmatch '\bResourceNotFound\b') { + Assert-AzCommandSucceeded -Result $insightsResult -Arguments $insightsArguments + } + + if ($insightsResult.ExitCode -eq 0) { + $insightsId = ($insightsResult.Lines -join '').Trim() + if (-not $insightsId) { throw 'Application Insights lookup returned success without a resource ID.' } + Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` + -Role 'Monitoring Metrics Publisher' -Scope $insightsId + + Invoke-Az rest --method patch --url "${insightsId}?api-version=2020-02-02" ` + --body '{\"properties\":{\"DisableLocalAuth\":true}}' ` + --headers 'Content-Type=application/json' -o none --only-show-errors | Out-Null + + $appSettings['APPLICATIONINSIGHTS_AUTHENTICATION_STRING'] = 'Authorization=AAD' + Write-Host ' App Insights : Entra auth, ingestion key disabled' + } + + $written = Set-FunctionAppSettings -Name $FunctionAppName -ResourceGroup $ResourceGroup ` + -SubscriptionId $resolvedSubscriptionId -Settings $appSettings + + Write-Host " App settings : $($appSettings.Count) applied, $written total" + + if (-not $NoEasyAuth) { + # A newly created app starts on auth v1, and every v2 command refuses to run until it is + # upgraded -- including the one below. The upgrade is a no-op on an app already on v2, so it + # is unconditional apart from the check that keeps the log honest. + $authVersion = (Invoke-Az webapp auth config-version show ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --query configVersion -o tsv) + + if ($authVersion -ne 'v2') { + Invoke-Az webapp auth config-version upgrade ` + --name $FunctionAppName ` + --resource-group $ResourceGroup | Out-Null + + Write-Host " Auth config : upgraded $authVersion -> v2" + } + + # allowedApplications is the part that matters and the part that is easy to leave out. + # + # Because assignment is not required on the endpoint service principal, any application in + # this tenant can ask Entra for a token audienced to this endpoint and will get one. Easy + # Auth on its own only proves the token is real and meant for this resource, so without an + # allowed-caller list any internal application could post forged passcodes. Pinning the + # caller to Microsoft's first-party application is what closes that. + $authSettings = @{ + platform = @{ + enabled = $true + runtimeVersion = '~1' + } + globalValidation = @{ + requireAuthentication = $true + + # Must not be RedirectToLoginPage. A 302 carrying an HTML sign-in page is not a 2xx, + # so Microsoft would record a failed delivery and re-send over native telephony. + unauthenticatedClientAction = 'Return401' + } + identityProviders = @{ + azureActiveDirectory = @{ + enabled = $true + registration = @{ + openIdIssuer = $issuer + clientId = $appId + } + validation = @{ + allowedAudiences = @($expectedAudience) + defaultAuthorizationPolicy = @{ + allowedApplications = @($MicrosoftPhoneProviderAppId) + } + } + } + } + + # Nothing here is a sign-in, so there is no token worth storing and no reason to pay for + # the storage round trip on a path with a 3.2 s budget. + login = @{ + tokenStore = @{ enabled = $false } + } + } + + # Written without a byte order mark: the Azure CLI reads @file as UTF-8 and a BOM makes the + # JSON parse fail with an unhelpful error. + $authFile = Join-Path ([System.IO.Path]::GetTempPath()) "epp-auth-$([Guid]::NewGuid()).json" + [System.IO.File]::WriteAllText( + $authFile, + ($authSettings | ConvertTo-Json -Depth 10), + [System.Text.UTF8Encoding]::new($false)) + + try { + # az webapp auth, not az functionapp auth. There is no functionapp equivalent, and the + # microsoft update subcommand cannot express allowedApplications, so the whole v2 + # settings document is written at once. + Invoke-Az webapp auth set ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --body "@$authFile" | Out-Null + } + finally { + Remove-Item $authFile -Force -ErrorAction SilentlyContinue + } + + Write-Host ' Easy Auth : enabled, 401 on anything not from Microsoft' + Write-Host ' Your function trigger must use AuthorizationLevel.Anonymous.' -ForegroundColor Yellow + Write-Host ' Easy Auth is the gate; a function key would only add a secret to the endpoint URL.' + } + else { + Write-Host ' Easy Auth : skipped, validate the bearer token in your own code' -ForegroundColor Yellow + } + + # Resolve where the package comes from. A local file wins, then an explicit URL, then the + # reference package Microsoft publishes — skipped while that is still a placeholder. + $packageToDeploy = $null + $downloadedPackage = $null + + if ($ZipPath) { + if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) { + throw "Zip package not found: $ZipPath" + } + + $packageToDeploy = (Resolve-Path -LiteralPath $ZipPath).Path + } + else { + # A local file wins, then an explicit URL, then the published reference package. The last of + # those is skipped while it is still a placeholder, so an unreleased build provisions + # everything and simply leaves the Function without code rather than failing on a bad host. + $sourceUrl = if ($ZipUrl) { + Read-SetupValue -Name ZipUrl -DefaultValue $ZipUrl -ValueType HttpsUrl -Secret + } + elseif ($ReferencePackageUrl -notmatch '[<>]') { + $ReferencePackageUrl + } + else { + $null + } + + if ($sourceUrl) { + # Zip deploy pushes a local file. Flex Consumption does not honour + # WEBSITE_RUN_FROM_PACKAGE against a URL, so fetching the package here and pushing it is + # the one path that behaves the same on every plan. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $previousProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + $downloadedPackage = Join-Path ([System.IO.Path]::GetTempPath()) "epp-package-$([Guid]::NewGuid()).zip" + + # A blob URL usually carries a SAS token. Printing it whole would put a live credential in + # the console and in any transcript, so the query string is masked. + Write-Host " Package : $($sourceUrl -replace '\?.*$', '?')" + + try { + Invoke-WebRequest -Uri $sourceUrl -OutFile $downloadedPackage -UseBasicParsing + } + catch { + throw "Could not download the package. Check the URL and that its SAS token has not expired.`n$($_.Exception.Message)" + } + finally { + $ProgressPreference = $previousProgress + } + + $packageToDeploy = $downloadedPackage + } + } + + if ($packageToDeploy) { + try { + Confirm-SetupAction -Action 'deploy the Function package' -Target $FunctionAppName ` + -Details 'This updates the Function code and writes its deployment package to storage.' + Invoke-Az functionapp deployment source config-zip ` + --name $FunctionAppName ` + --resource-group $ResourceGroup ` + --src $packageToDeploy | Out-Null + + Write-Host ' Deployed : package pushed' + } + finally { + if ($downloadedPackage) { + Remove-Item $downloadedPackage -Force -ErrorAction SilentlyContinue + } + } + } + else { + Write-Host ' No package supplied. Deploy your code before requesting enablement.' -ForegroundColor Yellow + } +} + +# --------------------------------------------------------------------------- +# 10. Tag everything +# --------------------------------------------------------------------------- +# Done as a sweep rather than only at create time. 'az functionapp create' brings up an App Service +# plan and an Application Insights component of its own accord, and neither takes a tag from this +# script, so tagging only what is created explicitly leaves resources the portal will not find. +# Incremental, so any tags a customer already applies are left alone. +if ($provisionFunction -and $StartFromStep -le 10) { + Write-Step 'Tagging resources' + + Invoke-Az tag update ` + --resource-id "/subscriptions/$resolvedSubscriptionId/resourceGroups/$ResourceGroup" ` + --operation Merge --tags $resourceTag --output none | Out-Null + $resourceIds = @(Invoke-Az resource list --resource-group $ResourceGroup --query "[].id" -o tsv) + + foreach ($resourceId in $resourceIds) { + if ([string]::IsNullOrWhiteSpace($resourceId)) { continue } + + $tagArguments = @('resource', 'tag', '--ids', $resourceId, '--tags', $resourceTag, + '--is-incremental', '--output', 'none', '--only-show-errors') + $tagResult = Invoke-AzResult -Arguments $tagArguments + if ($tagResult.ExitCode -ne 0) { + if (Test-AuthenticationFailure ($tagResult.Lines -join "`n")) { + Assert-AzCommandSucceeded -Result $tagResult -Arguments $tagArguments + } + # Some resource types reject tagging. Not worth failing a provisioning run over. + Write-Warning "Could not tag $($resourceId.Split('/')[-1]): $($tagResult.Lines -join ' ')" + } + } + + Write-Host " Tag : $ResourceTagName = $ResourceTagValue" + Write-Host " Applied to : $($resourceIds.Count) resources and the resource group" +} + +# --------------------------------------------------------------------------- +# 11. Summary +# --------------------------------------------------------------------------- +Write-Step 'Stage 2 complete: save these values for policy activation' + +$stageResult = [PSCustomObject]@{ + Stage = 2 + TenantId = $tenantId + EndpointUrl = $EndpointUrl + ApplicationId = $appId + IdentifierUri = $identifierUri + EncryptionKeyId = $keyId + CertThumbprint = $certificate.Thumbprint +} + +if ($provisionFunction) { + Write-Host "Private key is in Key Vault '$KeyVaultName' as 'phone-provider-decryption-key'." -ForegroundColor DarkGray + Write-Host 'No secret is stored in app settings; the Function resolves it with its managed identity.' -ForegroundColor DarkGray + Write-Host '' +} + +Write-Host 'Before requesting enablement, confirm your endpoint:' -ForegroundColor Yellow +Write-Host " 1. rejects any caller that is not $MicrosoftPhoneProviderAppId (Easy Auth, or your own code)" +Write-Host ' 2. decrypts the JWE using the private key named by kid' +Write-Host ' 3. returns 2xx with the SAME nonce it decrypted' +Write-Host ' 4. reads voice passcodes digit by digit' +Write-Host ' 5. responds within 3.2 seconds, delivering asynchronously' +Write-Host 'CYOT policy has not been enabled. Check its live Graph schema in the separate policy-activation stage.' -ForegroundColor Yellow +Write-SetupEvent -Level INFO -Message 'Stage 2 completed successfully. CYOT policy remains disabled pending stage 3.' +$stageSucceeded = $true +} +catch { + Write-SetupFailure -ErrorRecord $_ + if ($script:EventLogPath) { + Write-SetupEvent -Level WARN -Message "After correcting the failure, rerun with the same parameters and -StartFromStep $StartFromStep. Choose an earlier step if the error reports a missing prerequisite." + } + throw +} +finally { + if (-not $stageSucceeded -and $script:EventLogPath) { + Write-SetupEvent -Level WARN -Message 'Stage 2 ended before successful completion. Review the event log and transcript.' + } + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } +} + +if ($script:EventLogPath) { + Write-Host "Event log : $script:EventLogPath" -ForegroundColor DarkGray + Write-Host "Transcript: $script:TranscriptPath" -ForegroundColor DarkGray +} +$stageResult \ No newline at end of file diff --git a/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 b/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 new file mode 100644 index 0000000..38c8144 --- /dev/null +++ b/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 @@ -0,0 +1,448 @@ +#Requires -Version 7.0 +#Requires -Modules Microsoft.Graph.Authentication + +<# +.SYNOPSIS + Stage 3 of 3: check the live Graph contract, then explicitly activate/update CYOT when supported. +.DESCRIPTION + Run only after app registration, provider purchase/onboarding, and resource provisioning + have completed and the delivery endpoint has been tested. + + The design snapshot proposes authenticationMethodsPolicy.cyot { endpoint, appId, migrated }. + Live public v1.0 and beta metadata checked on 2026-09-14 DID NOT expose that contract. Therefore + this script intentionally refuses a policy write unless a fresh metadata check exposes the + exact supported shape. There is no bypass switch or guessed fallback to externalAuthenticationMethod. + A private-preview contract must be confirmed with CCE/Graph if it differs. + + When supported, sign in to the explicitly selected customer tenant with + Policy.ReadWrite.AuthenticationMethod. Authentication Policy Administrator is the least + privileged supported Entra role. Only the cyot property is patched; no SMS/Voice target lists, + authentication-method states or other policy fields are sent. Save the previous CYOT value, + confirm the operation, detect a changed policy before writing and verify the result afterwards. + This file is self-contained; it does not load or invoke any other setup script. + Only PowerShell and the Microsoft Graph authentication module are required. +.PARAMETER CheckSchemaOnly + Read public Graph metadata and report capability without sign-in, input prompts, backups or writes. +.PARAMETER GraphApiVersion + Public Graph API version to inspect and use. Defaults to beta. No preview URL is invented. +.PARAMETER TenantId + Customer tenant from stages 1 and 2. Required only when the metadata contract is supported. +.PARAMETER ApplicationId + Customer application CLIENT ID returned by stage 1 and reused in stage 2. +.PARAMETER EndpointUrl + Validated delivery endpoint returned by stage 2. +.PARAMETER Migrated + Required explicit path selector from the design: true for migration from native telephony, + false for a new CYOT-only tenant. If omitted, ask at the policy-update step; do not default to live. +.PARAMETER BackupPath + Optional new JSON path for the previous CYOT value. Existing files are never overwritten. + Otherwise a unique timestamped file is written next to this script, only after approval. +.EXAMPLE + .\Step3-Set-CyotPolicy.ps1 -CheckSchemaOnly +.EXAMPLE + .\Step3-Set-CyotPolicy.ps1 -TenantId -ApplicationId -EndpointUrl https://contoso-otp.azurewebsites.net/api/SendOtp -Migrated $true +#> +[CmdletBinding()] +param( + [string] $TenantId, + [string] $ApplicationId, + [string] $EndpointUrl, + [Nullable[bool]] $Migrated, + [ValidateSet('beta', 'v1.0')] + [string] $GraphApiVersion = 'beta', + [string] $BackupPath, + [switch] $CheckSchemaOnly, + [switch] $NonInteractive, + [switch] $ApprovePolicyActivation +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$script:AzureCliContext = $null +$script:GraphTenantId = $TenantId +$script:GraphAccountName = $null +$script:GraphRequiredScopes = @('Policy.ReadWrite.AuthenticationMethod') + +function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } + +function Read-SetupValue { + param( + [string] $Name, + $DefaultValue, + [switch] $Required, + [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] + [string] $ValueType = 'String', + [string[]] $Choices = @(), + [string] $Hint, + [switch] $Secret + ) + + $needsInput = $Required -and + ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) + while ($true) { + $value = $DefaultValue + if ($needsInput -and -not $NonInteractive) { + $prompt = "$Name [required]" + if ($Hint) { $prompt += " - $Hint" } + if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } + + if ($Secret) { + $secureValue = Read-Host -Prompt $prompt -AsSecureString + $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) + $secureValue.Dispose() + } + } + else { $answer = Read-Host -Prompt $prompt } + if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } + } + + $errorText = $null + if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { + if ($Required) { $errorText = "-$Name is required." } + else { return $null } + } + else { + switch ($ValueType) { + 'Integer' { + $number = 0 + if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { + $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." + } + else { $value = $number } + } + 'Guid' { + $identifier = [Guid]::Empty + if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { + $errorText = "-$Name must be a nonempty GUID." + } + else { $value = $identifier.ToString('D') } + } + 'Scope' { + $resource = "$value" -replace '/\.default$', '' + $resourceId = [Guid]::Empty + $resourceUri = $null + $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) + $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) + if ("$value" -notmatch '/\.default$' -or + ($isGuid -and $resourceId -eq [Guid]::Empty) -or + (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or + $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or + "$value" -match '\s') { + $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." + } + } + 'Boolean' { + if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } + elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } + else { $errorText = "-$Name must be Yes or No." } + } + 'Choice' { + if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } + else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } + } + 'File' { + if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } + } + { $_ -in @('HttpsUrl', 'Url') } { + $parsedUri = $null + if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or + $parsedUri.Scheme -notin @('http', 'https') -or + ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { + $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." + } + } + 'StorageName' { + if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } + } + 'VaultName' { + if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { + $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' + } + } + } + } + + if (-not $errorText) { return $value } + if (-not $needsInput -or $NonInteractive) { throw $errorText } + Write-Warning $errorText + } +} + +function Confirm-SetupAction { + param([string] $Action, [string] $Target, [string] $Details) + + if ($NonInteractive) { + if ($ApprovePolicyActivation) { + Write-Host " Approval : explicitly supplied for noninteractive policy activation" -ForegroundColor Yellow + return + } + throw "Approval required to $Action '$Target'. Supply -ApprovePolicyActivation or rerun without -NonInteractive; no automatic approval is assumed." + } + Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow + if ($script:AzureCliContext) { + Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" + } + if ($Details) { Write-Host " $Details" } + while ($true) { + $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') + $answer = $answer.Trim() + if ($answer -match '^(?i:y|yes)$') { return } + if (-not $answer -or $answer -match '^(?i:n|no)$') { + throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") + } + Write-Warning 'Enter Yes to approve, or No/empty to stop.' + } +} + +function Test-AuthenticationFailure { + param([string] $Message) + + # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. + return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + + 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + + 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + + '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + + '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + + 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') +} + +function Connect-EndpointGraph { + param([switch] $Reconnect, [string[]] $Scopes) + + if ($PSBoundParameters.ContainsKey('Scopes')) { + if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw 'Graph authentication requires at least one nonempty scope.' + } + $script:GraphRequiredScopes = $Scopes + } + + $context = Get-MgContext -ErrorAction Stop + $canReuse = $context -and $context.AuthType -eq 'Delegated' -and + $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and + $context.Environment -eq 'Global' -and + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and + (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) + + if ($Reconnect -or -not $canReuse) { + if ($NonInteractive) { + throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." + } + $connectParameters = @{ + Scopes = $script:GraphRequiredScopes + ContextScope = 'Process' + Environment = 'Global' + NoWelcome = $true + ErrorAction = 'Stop' + } + if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } + Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow + Connect-MgGraph @connectParameters | Out-Null + $context = Get-MgContext -ErrorAction Stop + } + + if (-not $context -or $context.AuthType -ne 'Delegated' -or + $context.Environment -ne 'Global' -or + @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or + ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or + ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { + throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' + } + $script:GraphTenantId = $context.TenantId + $script:GraphAccountName = $context.Account +} + +function Invoke-EndpointGraph { + param([scriptblock] $Operation) + + try { + & $Operation + } + catch { + $exception = $_.Exception + $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) + while ($exception) { + if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or + ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or + ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { + $authenticationFailure = $true + } + $exception = $exception.InnerException + } + if (-not $authenticationFailure) { throw } + Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow + Connect-EndpointGraph -Reconnect + & $Operation + } +} + + +function Get-CyotGraphSchemaStatus { + param([ValidateSet('beta', 'v1.0')] [string] $ApiVersion) + + $metadataUri = "https://graph.microsoft.com/$ApiVersion/`$metadata" + $response = Invoke-WebRequest -Uri $metadataUri -TimeoutSec 60 -ErrorAction Stop + $readerSettings = [Xml.XmlReaderSettings]::new() + $readerSettings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $readerSettings.XmlResolver = $null + $textReader = [IO.StringReader]::new([string]$response.Content) + $reader = [Xml.XmlReader]::Create($textReader, $readerSettings) + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + try { $document.Load($reader) } + finally { $reader.Dispose(); $textReader.Dispose() } + $ns = [Xml.XmlNamespaceManager]::new($document.NameTable) + $ns.AddNamespace('edm', 'http://docs.oasis-open.org/odata/ns/edm') + $schema = $document.SelectSingleNode('//edm:Schema[@Namespace="microsoft.graph"]', $ns) + if (-not $schema) { throw "Graph metadata at $metadataUri does not contain the microsoft.graph schema." } + $policyType = $schema.SelectSingleNode('edm:EntityType[@Name="authenticationMethodsPolicy"]', $ns) + if (-not $policyType) { throw 'The Graph schema does not contain authenticationMethodsPolicy.' } + + $property = $null + $visited = @{} + while ($policyType -and -not $property) { + $typeName = $policyType.GetAttribute('Name') + if ($visited.ContainsKey($typeName)) { throw 'Graph metadata contains cyclic policy inheritance.' } + $visited[$typeName] = $true + $property = $policyType.SelectSingleNode('edm:Property[@Name="cyot"]', $ns) + $baseName = ($policyType.GetAttribute('BaseType') -split '\.')[-1] + $policyType = $schema.SelectSingleNode("edm:EntityType[@Name='$baseName']", $ns) + } + + $supported = $false + $reason = 'authenticationMethodsPolicy.cyot is not declared in the live Graph schema.' + if ($property) { + $qualifiedType = $property.GetAttribute('Type') + $typeName = ($qualifiedType -split '\.')[-1] + $allowedPrefixes = @('microsoft.graph') + if ($schema.GetAttribute('Alias')) { $allowedPrefixes += $schema.GetAttribute('Alias') } + $prefix = $qualifiedType.Substring(0, [Math]::Max(0, $qualifiedType.Length - $typeName.Length - 1)) + $configuration = $schema.SelectSingleNode("edm:ComplexType[@Name='$typeName']", $ns) + $reason = 'The cyot property is present, but its type does not match the supported endpoint/appId/migrated contract.' + if ($configuration -and $allowedPrefixes -contains $prefix) { + $fields = @($configuration.SelectNodes('edm:Property', $ns)) + $expected = @{ endpoint = 'Edm.String'; appId = 'Edm.String'; migrated = 'Edm.Boolean' } + $supported = $fields.Count -eq 3 -and -not $configuration.HasAttribute('BaseType') -and + $configuration.GetAttribute('OpenType') -ne 'true' + foreach ($name in $expected.Keys) { + $field = $configuration.SelectSingleNode("edm:Property[@Name='$name']", $ns) + if (-not $field -or $field.GetAttribute('Type') -ne $expected[$name]) { $supported = $false } + } + if ($supported) { $reason = 'The declared CYOT property matches endpoint, appId and migrated. Tenant authorization and feature availability are still required.' } + } + } + return [PSCustomObject]@{ + ApiVersion = $ApiVersion + MetadataUri = $metadataUri + PolicyUri = "https://graph.microsoft.com/$ApiVersion/policies/authenticationMethodsPolicy" + Supported = $supported + Reason = $reason + CheckedAtUtc = [DateTime]::UtcNow.ToString('o') + } +} + +function Get-CyotPolicyState { + param($Policy) + + if (-not $Policy -or -not $Policy.PSObject.Properties['cyot'] -or $null -eq $Policy.cyot) { return $null } + $configuration = $Policy.cyot + $unknown = @($configuration.PSObject.Properties.Name | Where-Object { + $_ -notin @('endpoint', 'appId', 'migrated', '@odata.type') + }) + if ($unknown.Count) { throw "Existing CYOT policy contains unsupported fields: $($unknown -join ', '). Nothing will be overwritten." } + if (-not $configuration.PSObject.Properties['endpoint'] -or -not $configuration.PSObject.Properties['appId']) { + throw 'Existing CYOT policy is missing its endpoint or appId. Resolve the policy contract before updating.' + } + return [ordered]@{ + endpoint = $configuration.endpoint + appId = $configuration.appId + migrated = $(if ($configuration.PSObject.Properties['migrated']) { $configuration.migrated } else { $null }) + } +} + +function Invoke-CyotPolicyUpdate { + param( + $SchemaStatus, [string] $CustomerTenantId, [string] $ClientId, + [string] $DeliveryEndpoint, [Nullable[bool]] $MigrationPath, [string] $SnapshotPath + ) + + if (-not $SchemaStatus.Supported) { + throw "$($SchemaStatus.Reason) No policy change was attempted. Ask CCE/Graph for the supported preview contract before activation." + } + $CustomerTenantId = Read-SetupValue -Name TenantId -DefaultValue $CustomerTenantId -Required -ValueType Guid + $script:GraphTenantId = $CustomerTenantId + Connect-EndpointGraph -Scopes @('Policy.ReadWrite.AuthenticationMethod') + $current = Invoke-EndpointGraph { + Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop + } + $previous = Get-CyotPolicyState -Policy $current + + $ClientId = Read-SetupValue -Name ApplicationId -DefaultValue $ClientId -Required -ValueType Guid + $DeliveryEndpoint = Read-SetupValue -Name EndpointUrl -DefaultValue $DeliveryEndpoint -Required -ValueType HttpsUrl + $uri = [Uri]::new($DeliveryEndpoint) + if ($uri.IsLoopback -or $uri.HostNameType -in @('IPv4', 'IPv6') -or $uri.UserInfo -or $uri.Fragment) { + throw 'The CYOT endpoint must use a public HTTPS hostname without embedded credentials or a fragment.' + } + $MigrationPath = Read-SetupValue -Name Migrated -DefaultValue $MigrationPath -Required -ValueType Boolean ` + -Hint 'Yes: migrate from native telephony. No: new CYOT-only tenant. Choose deliberately.' + $desired = [ordered]@{ endpoint = $DeliveryEndpoint; appId = $ClientId; migrated = [bool]$MigrationPath } + $previousJson = ConvertTo-Json -InputObject $previous -Depth 10 -Compress + $desiredJson = ConvertTo-Json -InputObject $desired -Depth 10 -Compress + if ($previousJson -ceq $desiredJson) { + Write-Host ' CYOT policy : already matches; no update or approval needed' + return [PSCustomObject]@{ Stage = 3; TenantId = $CustomerTenantId; Updated = $false; PolicyUri = $SchemaStatus.PolicyUri } + } + + $body = @{ cyot = $desired } | ConvertTo-Json -Depth 10 + Confirm-SetupAction -Action 'update CYOT authentication policy' -Target $CustomerTenantId ` + -Details "This can change SMS/Voice sign-in routing. Confirm the provider is purchased, onboarded and the stage-2 endpoint tested. Only this property will be patched:`n$body" + $latest = Invoke-EndpointGraph { + Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop + } + $latestJson = ConvertTo-Json -InputObject (Get-CyotPolicyState -Policy $latest) -Depth 10 -Compress + if ($latestJson -cne $previousJson) { + throw 'CYOT policy changed while awaiting approval. No PATCH was sent; rerun to review the new state.' + } + + if ([string]::IsNullOrWhiteSpace($SnapshotPath)) { + $SnapshotPath = Join-Path $PSScriptRoot "cyot-policy-before-$CustomerTenantId-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$([Guid]::NewGuid().ToString('N')).json" + } + $snapshot = @{ + TenantId = $CustomerTenantId; PolicyUri = $SchemaStatus.PolicyUri + SavedAtUtc = [DateTime]::UtcNow.ToString('o'); PreviousCyot = $previous + } | ConvertTo-Json -Depth 10 + $stream = [IO.File]::Open($SnapshotPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) + try { + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($snapshot) + $stream.Write($bytes, 0, $bytes.Length) + } + finally { $stream.Dispose() } + Write-Host " Policy backup : $SnapshotPath" + $headers = @{} + if ($latest.PSObject.Properties['@odata.etag']) { $headers['If-Match'] = $latest.'@odata.etag' } + Invoke-EndpointGraph { + Invoke-MgGraphRequest -Method PATCH -Uri $SchemaStatus.PolicyUri -Body $body ` + -ContentType 'application/json' -Headers $headers -ErrorAction Stop + } | Out-Null + $after = Invoke-EndpointGraph { + Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop + } + if ((ConvertTo-Json -InputObject (Get-CyotPolicyState -Policy $after) -Depth 10 -Compress) -cne $desiredJson) { + throw "Graph accepted the update but readback does not match. Do not assume CYOT is active. Previous value: $SnapshotPath" + } + return [PSCustomObject]@{ + Stage = 3; TenantId = $CustomerTenantId; ApplicationId = $ClientId; EndpointUrl = $DeliveryEndpoint + Migrated = [bool]$MigrationPath; Updated = $true; PolicyUri = $SchemaStatus.PolicyUri; BackupPath = $SnapshotPath + } +} + +Write-Step 'Stage 3: checking the live CYOT Graph contract' +$schemaStatus = Get-CyotGraphSchemaStatus -ApiVersion $GraphApiVersion +if ($CheckSchemaOnly) { + $schemaStatus + return +} +Invoke-CyotPolicyUpdate -SchemaStatus $schemaStatus -CustomerTenantId $TenantId -ClientId $ApplicationId ` + -DeliveryEndpoint $EndpointUrl -MigrationPath $Migrated -SnapshotPath $BackupPath diff --git a/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 b/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 new file mode 100644 index 0000000..480b1b1 --- /dev/null +++ b/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 @@ -0,0 +1,134 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$packageRoot = Split-Path -Parent $PSScriptRoot +$entryPoint = Join-Path $packageRoot 'Setup-Cyot.ps1' +$failures = [Collections.Generic.List[string]]::new() + +function Invoke-TestProcess { + param([string[]] $Arguments) + + $output = & (Get-Command pwsh -ErrorAction Stop).Source -NoProfile -File $entryPoint @Arguments 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } +} + +function Test-Condition { + param([string] $Name, [bool] $Condition, [string] $Detail) + + if ($Condition) { + Write-Host "PASS: $Name" -ForegroundColor Green + return + } + $failures.Add("${Name}: $Detail") + Write-Host "FAIL: $Name - $Detail" -ForegroundColor Red +} + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) "cyot-smoke-$([Guid]::NewGuid().ToString('N'))" +try { + New-Item -ItemType Directory -Path $testRoot -Force | Out-Null + + $diagnostics = Invoke-TestProcess -Arguments @('-Stage', 'Diagnostics', '-StatePath', (Join-Path $testRoot 'diagnostics-state.json')) + Test-Condition 'Diagnostics completes locally' ($diagnostics.ExitCode -eq 0 -and $diagnostics.Output -match 'Diagnostics completed') $diagnostics.Output + + $invalidConfigPath = Join-Path $testRoot 'invalid.json' + [IO.File]::WriteAllText($invalidConfigPath, '{ invalid json', [Text.UTF8Encoding]::new($false)) + $invalidConfig = Invoke-TestProcess -Arguments @('-Stage', 'Diagnostics', '-ConfigPath', $invalidConfigPath, '-StatePath', (Join-Path $testRoot 'invalid-state.json')) + Test-Condition 'Invalid JSON is rejected' ($invalidConfig.ExitCode -ne 0 -and $invalidConfig.Output -match 'JSON') $invalidConfig.Output + + $activationStatePath = Join-Path $testRoot 'activation-state.json' + @{ + schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') + tenantId = '11111111-1111-1111-1111-111111111111' + applicationId = '22222222-2222-2222-2222-222222222222' + endpointUrl = 'https://example.com/api/SendOtp' + graphSchemaSupported = $true; policyUpdated = $false + completedStages = @('Register', 'Deploy', 'Validate') + } | ConvertTo-Json | Set-Content -LiteralPath $activationStatePath -Encoding utf8NoBOM + $activation = Invoke-TestProcess -Arguments @('-Stage', 'Activate', '-NonInteractive', '-StatePath', $activationStatePath) + Test-Condition 'Noninteractive activation requires explicit approval' ` + ($activation.ExitCode -ne 0 -and $activation.Output -match 'requires -ApprovePolicyActivation') $activation.Output + + $temporaryPackage = Join-Path $testRoot 'package' + New-Item -ItemType Directory -Path (Join-Path $temporaryPackage 'stages') -Force | Out-Null + Copy-Item -LiteralPath $entryPoint -Destination (Join-Path $temporaryPackage 'Setup-Cyot.ps1') + $temporaryEntryPoint = Join-Path $temporaryPackage 'Setup-Cyot.ps1' + $entryPoint = $temporaryEntryPoint + + $missingStage = Invoke-TestProcess -Arguments @('-Stage', 'Register', '-NonInteractive', '-StatePath', (Join-Path $testRoot 'missing-stage-state.json')) + Test-Condition 'Missing stage is reported before execution' ` + ($missingStage.ExitCode -ne 0 -and $missingStage.Output -match 'Packaged Register stage script is missing') $missingStage.Output + + @' +[CmdletBinding()] +param([string] $TenantId, [string] $ApplicationId, [string] $DisplayName, [switch] $NonInteractive, [switch] $SkipAzureLogin, [string] $LogDirectory) +'33333333-3333-3333-3333-333333333333' +'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Step1-Register-CyotApplication.ps1') -Encoding utf8NoBOM + $configPath = Join-Path $testRoot 'customer.json' + @{ setup = @{ tenantId = '11111111-1111-1111-1111-111111111111' }; registration = @{ skipAzureLogin = $true } } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM + $statePath = Join-Path $testRoot 'state/cyot.json' + $registration = Invoke-TestProcess -Arguments @('-Stage', 'Register', '-NonInteractive', '-ConfigPath', $configPath, '-StatePath', $statePath) + $state = if (Test-Path -LiteralPath $statePath) { Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json } else { $null } + Test-Condition 'Registration output is normalized and state is saved' ` + ($registration.ExitCode -eq 0 -and $state.applicationId -eq '33333333-3333-3333-3333-333333333333' -and $state.completedStages -contains 'Register') $registration.Output + + @' +[CmdletBinding()] +param([string] $SubscriptionId, [string] $ResourceGroup, [string] $Location, [string] $EnvironmentName, [string] $PlanType, [switch] $NonInteractive) +[pscustomobject]@{ + Stage = 'Infrastructure'; SubscriptionId = $SubscriptionId; ResourceGroup = $ResourceGroup; Location = $Location + PlanType = $PlanType; FunctionAppName = 'cyot-prod-func-test'; StorageAccountName = 'cyotprodstoragetest'; KeyVaultName = 'cyot-prod-kv-test' +} +'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Deploy-CyotInfrastructure.ps1') -Encoding utf8NoBOM + @' +[CmdletBinding()] +param([string] $ApplicationId, [string] $LogDirectory, [string] $SubscriptionId, [string] $ResourceGroup, [string] $Location, + [string] $FunctionAppName, [string] $StorageAccountName, [string] $KeyVaultName, [string] $PlanType, [switch] $NonInteractive) +[pscustomobject]@{ + Stage = 2; TenantId = '11111111-1111-1111-1111-111111111111'; EndpointUrl = "https://$FunctionAppName.azurewebsites.net/api/SendOtp" + ApplicationId = $ApplicationId; IdentifierUri = "api://$ApplicationId"; EncryptionKeyId = 'test-key'; CertThumbprint = 'TEST' +} +'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Step2-Setup-ExternalPhoneProvider.ps1') -Encoding utf8NoBOM + $bicepConfigPath = Join-Path $testRoot 'bicep.json' + @{ + endpoint = @{ + infrastructureMode = 'Bicep'; subscriptionId = '44444444-4444-4444-4444-444444444444' + resourceGroup = 'rg-cyot-test'; location = 'eastus'; environmentName = 'prod'; planType = 'Premium' + } + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $bicepConfigPath -Encoding utf8NoBOM + $bicepStatePath = Join-Path $testRoot 'bicep-state.json' + @{ + schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') + applicationId = '33333333-3333-3333-3333-333333333333'; completedStages = @('Register') + } | ConvertTo-Json | Set-Content -LiteralPath $bicepStatePath -Encoding utf8NoBOM + $bicepDeploy = Invoke-TestProcess -Arguments @('-Stage', 'Deploy', '-NonInteractive', '-ConfigPath', $bicepConfigPath, '-StatePath', $bicepStatePath) + $bicepState = if (Test-Path -LiteralPath $bicepStatePath) { Get-Content -LiteralPath $bicepStatePath -Raw | ConvertFrom-Json } else { $null } + Test-Condition 'Bicep outputs are forwarded and persisted for resume' ` + ($bicepDeploy.ExitCode -eq 0 -and $bicepState.functionAppName -eq 'cyot-prod-func-test' -and + $bicepState.storageAccountName -eq 'cyotprodstoragetest' -and $bicepState.keyVaultName -eq 'cyot-prod-kv-test' -and + $bicepState.planType -eq 'Premium' -and $bicepState.completedStages -contains 'Deploy') $bicepDeploy.Output + + Remove-Item -LiteralPath (Join-Path $temporaryPackage 'stages/Step2-Setup-ExternalPhoneProvider.ps1') -Force + $resumeStatePath = Join-Path $testRoot 'resume-state.json' + @{ + schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') + tenantId = '11111111-1111-1111-1111-111111111111' + applicationId = '33333333-3333-3333-3333-333333333333' + completedStages = @('Register') + } | ConvertTo-Json | Set-Content -LiteralPath $resumeStatePath -Encoding utf8NoBOM + $resume = Invoke-TestProcess -Arguments @('-Resume', '-NonInteractive', '-StatePath', $resumeStatePath) + Test-Condition 'Resume starts with the first incomplete stage' ` + ($resume.ExitCode -ne 0 -and $resume.Output -match 'Packaged Deploy stage script is missing' -and $resume.Output -notmatch 'Packaged Register stage script is missing') $resume.Output +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($failures.Count) { + throw "Smoke tests failed:`n$($failures -join "`n")" +} +Write-Host 'All CYOT setup smoke tests passed.' -ForegroundColor Green +exit 0 \ No newline at end of file diff --git a/README.md b/README.md index 9b04ac9..21951ee 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, and deploying, step by step. +## Guided CYOT setup + +Use **[CYOT-Setup](CYOT-Setup/docs/README.md)** for a PowerShell-guided setup that registers the +customer application, provisions or connects an External Phone Provider endpoint, validates the +configuration, and activates the CYOT policy only after explicit approval. The setup supports Bicep +or Azure CLI provisioning, redacted logs, diagnostics, and resumable stages. + ## Download a Function ZIP Download the preview ZIP for your chosen language: From c3e8ad3b64cf6b25b445da841dfab1c609713c96 Mon Sep 17 00:00:00 2001 From: James Xian Date: Tue, 15 Sep 2026 19:16:01 -0700 Subject: [PATCH 2/2] Simplify CYOT endpoint setup and support fork-based testing --- .github/workflows/ci.yml | 16 + CYOT-Setup/CYOT-Setup.psd1 | 17 - CYOT-Setup/Setup-Cyot.ps1 | 493 ---- CYOT-Setup/docs/README.md | 234 -- CYOT-Setup/docs/Troubleshooting.md | 55 - .../examples/customer-config.example.json | 29 - CYOT-Setup/infra/main.bicep | 43 - CYOT-Setup/infra/main.parameters.json | 24 - CYOT-Setup/infra/resources.bicep | 241 -- .../stages/Deploy-CyotInfrastructure.ps1 | 130 - .../stages/Step1-Register-CyotApplication.ps1 | 568 ----- .../Step2-Setup-ExternalPhoneProvider.ps1 | 2087 ----------------- CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 | 448 ---- CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 | 134 -- README.md | 46 +- docs/CONTRACT.md | 22 +- docs/ONBOARDING.md | 52 +- dotnet/README.md | 5 +- dotnet/Src/AppConfig.cs | 12 + dotnet/Src/DispatchEngine.cs | 65 +- dotnet/Src/Models.cs | 2 +- dotnet/Src/Providers/SopranoProvider.cs | 7 +- dotnet/Src/Providers/TelesignProvider.cs | 5 +- dotnet/tests/ContractTests.cs | 17 +- dotnet/tests/EngineTests.cs | 18 +- javascript/README.md | 10 +- javascript/src/functions/config.js | 6 + javascript/src/functions/dispatch.js | 57 +- javascript/src/functions/providers/soprano.js | 13 +- .../src/functions/providers/telesign.js | 5 +- javascript/test/dispatch.test.js | 38 +- javascript/test/sendotp.test.js | 15 +- python/README.md | 5 +- python/src/config.py | 14 + python/src/dispatch.py | 57 +- python/src/models.py | 2 + python/src/providers/soprano.py | 11 +- python/src/providers/telesign.py | 4 +- python/tests/test_contract.py | 14 +- python/tests/test_engine.py | 22 +- python/tests/test_function_app.py | 4 +- {CYOT-Setup => setup}/.gitignore | 1 + setup/EPP-Setup.psd1 | 14 + setup/Setup-Epp.ps1 | 84 + setup/docs/README.md | 228 ++ setup/docs/Troubleshooting.md | 166 ++ setup/infra/main.bicep | 55 + setup/infra/resources.bicep | 335 +++ setup/packages/catalog.json | 26 + setup/providers/catalog.json | 15 + setup/providers/soprano.json | 46 + setup/providers/telesign.json | 43 + setup/support/Epp.Packages.ps1 | 156 ++ setup/support/Epp.Setup.psm1 | 1032 ++++++++ 54 files changed, 2579 insertions(+), 4669 deletions(-) delete mode 100644 CYOT-Setup/CYOT-Setup.psd1 delete mode 100644 CYOT-Setup/Setup-Cyot.ps1 delete mode 100644 CYOT-Setup/docs/README.md delete mode 100644 CYOT-Setup/docs/Troubleshooting.md delete mode 100644 CYOT-Setup/examples/customer-config.example.json delete mode 100644 CYOT-Setup/infra/main.bicep delete mode 100644 CYOT-Setup/infra/main.parameters.json delete mode 100644 CYOT-Setup/infra/resources.bicep delete mode 100644 CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 delete mode 100644 CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 delete mode 100644 CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 delete mode 100644 CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 delete mode 100644 CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 rename {CYOT-Setup => setup}/.gitignore (88%) create mode 100644 setup/EPP-Setup.psd1 create mode 100644 setup/Setup-Epp.ps1 create mode 100644 setup/docs/README.md create mode 100644 setup/docs/Troubleshooting.md create mode 100644 setup/infra/main.bicep create mode 100644 setup/infra/resources.bicep create mode 100644 setup/packages/catalog.json create mode 100644 setup/providers/catalog.json create mode 100644 setup/providers/soprano.json create mode 100644 setup/providers/telesign.json create mode 100644 setup/support/Epp.Packages.ps1 create mode 100644 setup/support/Epp.Setup.psm1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6f0a9..35a290f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,22 @@ on: pull_request: jobs: + epp-setup: + name: EPP setup (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Compile Bicep without deploying + shell: pwsh + run: | + az bicep install + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + az bicep build --file (Join-Path $env:GITHUB_WORKSPACE 'setup/infra/main.bicep') --outfile (Join-Path $env:RUNNER_TEMP 'epp-main.json') + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + javascript: name: JavaScript (Node.js) runs-on: ubuntu-latest diff --git a/CYOT-Setup/CYOT-Setup.psd1 b/CYOT-Setup/CYOT-Setup.psd1 deleted file mode 100644 index 308c88a..0000000 --- a/CYOT-Setup/CYOT-Setup.psd1 +++ /dev/null @@ -1,17 +0,0 @@ -@{ - PackageName = 'CYOT guided setup' - PackageVersion = '0.1.0' - EntryPoint = 'Setup-Cyot.ps1' - MinimumPowerShellVersion = '7.0' - Stages = @( - 'stages/Step1-Register-CyotApplication.ps1' - 'stages/Deploy-CyotInfrastructure.ps1' - 'stages/Step2-Setup-ExternalPhoneProvider.ps1' - 'stages/Step3-Set-CyotPolicy.ps1' - ) - Infrastructure = @( - 'infra/main.bicep' - 'infra/resources.bicep' - ) - RuntimeDirectories = @('logs', 'state', 'policy-backups') -} diff --git a/CYOT-Setup/Setup-Cyot.ps1 b/CYOT-Setup/Setup-Cyot.ps1 deleted file mode 100644 index 3f6f35a..0000000 --- a/CYOT-Setup/Setup-Cyot.ps1 +++ /dev/null @@ -1,493 +0,0 @@ -#Requires -Version 7.0 - -<# -> **Produced by:** GitHub Copilot | **Session:** S0915a - -.SYNOPSIS - Guided setup for Custom OTP (CYOT) with Microsoft Entra ID. - -.DESCRIPTION - Runs application registration, endpoint setup, validation, and policy activation as one guided - experience. Each stage remains independently rerunnable. Progress is written atomically to a - local state file so an interrupted setup can resume without storing credentials or access tokens. - - Policy activation remains a separate safety gate. The live Microsoft Graph metadata contract is - checked before policy permissions are requested or a write is attempted. - -.PARAMETER Stage - Stage to run. Omit for the guided menu. All runs Register, Deploy, Validate, then Activate. - -.PARAMETER Resume - Continue with the first incomplete stage recorded in the state file. - -.PARAMETER ConfigPath - Optional JSON configuration file. Values supplied as parameters or collected by stage scripts - take precedence over omitted configuration values. - -.PARAMETER StatePath - Progress file. Defaults to state/cyot-setup-state.json beside this script. - -.PARAMETER NonInteractive - Do not display the setup menu or allow stage scripts to request missing values. - -.PARAMETER ApprovePolicyActivation - Explicitly authorizes policy activation in noninteractive mode. This does not bypass Graph schema, - concurrency, backup, or readback safeguards. - -.EXAMPLE - .\Setup-Cyot.ps1 - -.EXAMPLE - .\Setup-Cyot.ps1 -Resume - -.EXAMPLE - .\Setup-Cyot.ps1 -NonInteractive -ConfigPath .\customer-config.json -#> -[CmdletBinding()] -param( - [ValidateSet('All', 'Register', 'Deploy', 'Validate', 'Activate', 'Diagnostics')] - [string] $Stage, - - [switch] $Resume, - - [string] $ConfigPath, - - [string] $StatePath = (Join-Path $PSScriptRoot 'state/cyot-setup-state.json'), - - [switch] $NonInteractive, - - [switch] $ApprovePolicyActivation -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -$script:PackageRoot = $PSScriptRoot -$script:StageDirectory = Join-Path $PSScriptRoot 'stages' -$script:LogDirectory = Join-Path $PSScriptRoot 'logs' -$script:PolicyBackupDirectory = Join-Path $PSScriptRoot 'policy-backups' -$script:StageScripts = @{ - Register = Join-Path $script:StageDirectory 'Step1-Register-CyotApplication.ps1' - Infrastructure = Join-Path $script:StageDirectory 'Deploy-CyotInfrastructure.ps1' - Deploy = Join-Path $script:StageDirectory 'Step2-Setup-ExternalPhoneProvider.ps1' - Activate = Join-Path $script:StageDirectory 'Step3-Set-CyotPolicy.ps1' -} -$script:StageOrder = @('Register', 'Deploy', 'Validate', 'Activate') -$script:EventLogPath = $null - -function Protect-CyotLogText { - param([AllowEmptyString()][string] $Text) - - if ([string]::IsNullOrEmpty($Text)) { return $Text } - $safeText = $Text -replace '(?i)(Authorization\s*[:=]\s*Bearer\s+)[^\s,;]+', '$1[REDACTED]' - $safeText = $safeText -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' - return $safeText -} - -function Write-CyotEvent { - param( - [ValidateSet('INFO', 'WARN', 'ERROR')] - [string] $Level, - [string] $Message - ) - - $safeMessage = Protect-CyotLogText -Text $Message - $entry = '{0:o} [{1}] {2}' -f [DateTimeOffset]::Now, $Level, $safeMessage - Add-Content -LiteralPath $script:EventLogPath -Value $entry -Encoding utf8 - Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) -} - -function Initialize-CyotWorkspace { - foreach ($directory in @($script:LogDirectory, (Split-Path -Parent $StatePath), $script:PolicyBackupDirectory)) { - if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - } - $script:EventLogPath = Join-Path $script:LogDirectory "setup-cyot-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$PID.log" - New-Item -ItemType File -Path $script:EventLogPath -Force | Out-Null -} - -function ConvertTo-CyotHashtable { - param($InputObject) - - if ($null -eq $InputObject) { return $null } - if ($InputObject -is [Collections.IDictionary]) { - $dictionary = @{} - foreach ($key in $InputObject.Keys) { $dictionary[$key] = ConvertTo-CyotHashtable $InputObject[$key] } - return $dictionary - } - if ($InputObject -is [Management.Automation.PSCustomObject]) { - $dictionary = @{} - foreach ($property in $InputObject.PSObject.Properties) { - $dictionary[$property.Name] = ConvertTo-CyotHashtable $property.Value - } - return $dictionary - } - if ($InputObject -is [Collections.IEnumerable] -and $InputObject -isnot [string]) { - return @($InputObject | ForEach-Object { ConvertTo-CyotHashtable $_ }) - } - return $InputObject -} - -function Read-CyotConfig { - if ([string]::IsNullOrWhiteSpace($ConfigPath)) { return @{} } - if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) { - throw "Configuration file not found: $ConfigPath" - } - $resolvedPath = (Resolve-Path -LiteralPath $ConfigPath).Path - Write-CyotEvent -Level INFO -Message "Loading configuration from $resolvedPath." - return ConvertTo-CyotHashtable (Get-Content -LiteralPath $resolvedPath -Raw | ConvertFrom-Json) -} - -function New-CyotState { - return [ordered]@{ - schemaVersion = 1 - updatedAtUtc = [DateTime]::UtcNow.ToString('o') - tenantId = $null - applicationId = $null - subscriptionId = $null - resourceGroup = $null - functionAppName = $null - endpointUrl = $null - identifierUri = $null - encryptionKeyId = $null - certThumbprint = $null - policyUpdated = $false - completedStages = @() - } -} - -function Read-CyotState { - if (-not (Test-Path -LiteralPath $StatePath -PathType Leaf)) { return New-CyotState } - try { - $state = ConvertTo-CyotHashtable (Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json) - if ($state.schemaVersion -ne 1) { throw "Unsupported state schema version '$($state.schemaVersion)'." } - $state.completedStages = @($state.completedStages) - return $state - } - catch { - throw "Could not read state file '$StatePath'. $($_.Exception.Message)" - } -} - -function Save-CyotState { - param([Collections.IDictionary] $State) - - $State.updatedAtUtc = [DateTime]::UtcNow.ToString('o') - $stateDirectory = Split-Path -Parent $StatePath - $temporaryPath = Join-Path $stateDirectory ".cyot-state-$([Guid]::NewGuid().ToString('N')).tmp" - try { - $json = ConvertTo-Json -InputObject $State -Depth 10 - [IO.File]::WriteAllText($temporaryPath, $json, [Text.UTF8Encoding]::new($false)) - Move-Item -LiteralPath $temporaryPath -Destination $StatePath -Force - } - finally { - Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue - } -} - -function Get-CyotValue { - param( - [Collections.IDictionary] $Config, - [Collections.IDictionary] $State, - [string] $Name, - [string] $StateName = $Name - ) - - foreach ($sectionName in @('setup', 'registration', 'endpoint', 'activation')) { - if ($Config.Contains($sectionName) -and $Config[$sectionName] -is [Collections.IDictionary] -and - $Config[$sectionName].Contains($Name) -and $null -ne $Config[$sectionName][$Name]) { - return $Config[$sectionName][$Name] - } - } - if ($Config.Contains($Name) -and $null -ne $Config[$Name]) { return $Config[$Name] } - if ($State.Contains($StateName) -and $null -ne $State[$StateName]) { return $State[$StateName] } - return $null -} - -function Add-CyotArgument { - param([hashtable] $Arguments, [string] $Name, $Value) - - if ($null -eq $Value) { return } - if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { return } - $Arguments[$Name] = $Value -} - -function Assert-CyotStageScript { - param([string] $Name) - - $path = $script:StageScripts[$Name] - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "Packaged $Name stage script is missing: $path" - } - return $path -} - -function Complete-CyotStage { - param([Collections.IDictionary] $State, [string] $Name) - - if ($State.completedStages -notcontains $Name) { - $State.completedStages = @($State.completedStages) + $Name - } - Save-CyotState -State $State - Write-CyotEvent -Level INFO -Message "$Name stage completed. State saved to $StatePath." -} - -function Invoke-CyotRegister { - param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) - - $arguments = @{ LogDirectory = $script:LogDirectory } - Add-CyotArgument $arguments TenantId (Get-CyotValue $Config $State TenantId tenantId) - Add-CyotArgument $arguments ApplicationId (Get-CyotValue $Config $State ApplicationId applicationId) - Add-CyotArgument $arguments DisplayName (Get-CyotValue $Config $State DisplayName) - if ($NonInteractive) { $arguments.NonInteractive = $true } - if ((Get-CyotValue $Config $State SkipAzureLogin) -eq $true) { $arguments.SkipAzureLogin = $true } - - Write-CyotEvent -Level INFO -Message 'Starting application registration stage.' - $outputs = @(& (Assert-CyotStageScript Register) @arguments) - $applicationId = @($outputs | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-fA-F-]{36}$' }) | Select-Object -Last 1 - if ([string]::IsNullOrWhiteSpace($applicationId)) { - throw 'Registration stage did not return an application client ID.' - } - $State.applicationId = $applicationId - $tenantId = Get-CyotValue $Config $State TenantId tenantId - if ($tenantId) { $State.tenantId = $tenantId } - Complete-CyotStage $State Register -} - -function Invoke-CyotDeploy { - param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) - - if ([string]::IsNullOrWhiteSpace($State.applicationId)) { - throw 'Deploy requires applicationId. Run the Register stage first.' - } - - $infrastructureMode = Get-CyotValue $Config $State InfrastructureMode - $infrastructureResult = $null - if ($infrastructureMode -eq 'Bicep' -and [string]::IsNullOrWhiteSpace((Get-CyotValue $Config $State EndpointUrl endpointUrl))) { - $infrastructureArguments = @{} - foreach ($name in @('SubscriptionId', 'ResourceGroup', 'Location', 'EnvironmentName', 'ResourceTagName', 'ResourceTagValue', 'PlanType')) { - Add-CyotArgument $infrastructureArguments $name (Get-CyotValue $Config $State $name) - } - if ($NonInteractive) { $infrastructureArguments.NonInteractive = $true } - - Write-CyotEvent -Level INFO -Message 'Starting Bicep infrastructure deployment.' - $infrastructureOutputs = @(& (Assert-CyotStageScript Infrastructure) @infrastructureArguments) - $infrastructureResult = $infrastructureOutputs | - Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 'Infrastructure' } | - Select-Object -Last 1 - if ($null -eq $infrastructureResult) { - throw 'Bicep infrastructure deployment did not return its stage result.' - } - } - - $arguments = @{ ApplicationId = $State.applicationId; LogDirectory = $script:LogDirectory } - $parameterNames = @( - 'FunctionAppName', 'EndpointUrl', 'SubscriptionId', 'ResourceGroup', 'Location', - 'StorageAccountName', 'KeyVaultName', 'ResourceTagName', 'ResourceTagValue', 'PlanType', - 'ZipUrl', 'ZipPath', 'FunctionRoute', 'DisplayName', 'CertificatePath', 'ProviderName', - 'ProviderEndpoint', 'ProviderTimeoutMs', 'ProviderRetryIntervalMs', 'ProviderAccountName', - 'TenantId', 'ProviderTenantId', 'ProviderScope', 'OutboundIdentityName', 'StartFromStep' - ) - foreach ($name in $parameterNames) { Add-CyotArgument $arguments $name (Get-CyotValue $Config $State $name) } - if ($null -ne $infrastructureResult) { - foreach ($name in @('FunctionAppName', 'StorageAccountName', 'KeyVaultName', 'ResourceGroup', 'Location', 'PlanType')) { - $arguments[$name] = $infrastructureResult.$name - } - } - foreach ($switchName in @('NoEasyAuth', 'UseWindowsBroker')) { - if ((Get-CyotValue $Config $State $switchName) -eq $true) { $arguments[$switchName] = $true } - } - if ($NonInteractive) { $arguments.NonInteractive = $true } - - Write-CyotEvent -Level INFO -Message 'Starting endpoint deployment/configuration stage.' - $outputs = @(& (Assert-CyotStageScript Deploy) @arguments) - $result = $outputs | Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 2 } | Select-Object -Last 1 - if ($null -eq $result) { throw 'Deploy stage did not return its stage result.' } - foreach ($mapping in @{ - TenantId = 'tenantId'; EndpointUrl = 'endpointUrl'; ApplicationId = 'applicationId'; - IdentifierUri = 'identifierUri'; EncryptionKeyId = 'encryptionKeyId'; CertThumbprint = 'certThumbprint' - }.GetEnumerator()) { - if ($result.PSObject.Properties[$mapping.Key]) { $State[$mapping.Value] = $result.($mapping.Key) } - } - foreach ($mapping in @{ - SubscriptionId = 'subscriptionId'; ResourceGroup = 'resourceGroup'; FunctionAppName = 'functionAppName'; - StorageAccountName = 'storageAccountName'; KeyVaultName = 'keyVaultName'; Location = 'location'; PlanType = 'planType' - }.GetEnumerator()) { - $value = if ($arguments.Contains($mapping.Key)) { $arguments[$mapping.Key] } else { Get-CyotValue $Config $State $mapping.Key } - if ($value) { $State[$mapping.Value] = $value } - } - Complete-CyotStage $State Deploy -} - -function Invoke-CyotValidate { - param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) - - if ([string]::IsNullOrWhiteSpace($State.endpointUrl)) { - throw 'Validate requires endpointUrl. Run the Deploy stage first.' - } - $endpoint = [Uri]::new($State.endpointUrl) - if ($endpoint.Scheme -ne 'https' -or $endpoint.IsLoopback -or $endpoint.HostNameType -in @('IPv4', 'IPv6')) { - throw 'The endpoint must use a public HTTPS hostname.' - } - try { - $addresses = [Net.Dns]::GetHostAddresses($endpoint.DnsSafeHost) - Write-CyotEvent -Level INFO -Message "Endpoint DNS resolved to $($addresses.Count) address(es)." - } - catch { - throw "Endpoint DNS resolution failed for '$($endpoint.DnsSafeHost)'. $($_.Exception.Message)" - } - - $schemaArguments = @{ CheckSchemaOnly = $true } - Add-CyotArgument $schemaArguments GraphApiVersion (Get-CyotValue $Config $State GraphApiVersion) - $outputs = @(& (Assert-CyotStageScript Activate) @schemaArguments) - $schemaStatus = $outputs | Where-Object { $_.PSObject.Properties['Supported'] } | Select-Object -Last 1 - if ($null -eq $schemaStatus) { throw 'Policy stage did not return Graph schema status.' } - $State.graphSchemaSupported = [bool]$schemaStatus.Supported - $State.graphSchemaReason = $schemaStatus.Reason - Complete-CyotStage $State Validate - if (-not $schemaStatus.Supported) { - Write-CyotEvent -Level WARN -Message "CYOT policy activation is unavailable: $($schemaStatus.Reason)" - } -} - -function Invoke-CyotActivate { - param([Collections.IDictionary] $Config, [Collections.IDictionary] $State) - - foreach ($requiredName in @('tenantId', 'applicationId', 'endpointUrl')) { - if ([string]::IsNullOrWhiteSpace($State[$requiredName])) { - throw "Activate requires $requiredName. Complete the earlier stages first." - } - } - if ($State.Contains('graphSchemaSupported') -and -not $State.graphSchemaSupported) { - Write-CyotEvent -Level WARN -Message 'Activation skipped because the validated public Graph schema does not expose CYOT.' - return - } - if ($NonInteractive -and -not $ApprovePolicyActivation) { - throw 'Noninteractive activation requires -ApprovePolicyActivation. No policy change was attempted.' - } - - $arguments = @{ - TenantId = $State.tenantId - ApplicationId = $State.applicationId - EndpointUrl = $State.endpointUrl - BackupPath = Join-Path $script:PolicyBackupDirectory "cyot-policy-before-$($State.tenantId)-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$([Guid]::NewGuid().ToString('N')).json" - } - Add-CyotArgument $arguments Migrated (Get-CyotValue $Config $State Migrated) - Add-CyotArgument $arguments GraphApiVersion (Get-CyotValue $Config $State GraphApiVersion) - if ($NonInteractive) { - $arguments.NonInteractive = $true - $arguments.ApprovePolicyActivation = $true - } - - Write-CyotEvent -Level INFO -Message 'Starting explicit CYOT policy activation stage.' - $outputs = @(& (Assert-CyotStageScript Activate) @arguments) - $result = $outputs | Where-Object { $_.PSObject.Properties['Stage'] -and $_.Stage -eq 3 } | Select-Object -Last 1 - if ($null -eq $result) { throw 'Activation stage did not return its stage result.' } - $State.policyUpdated = [bool]$result.Updated - Complete-CyotStage $State Activate -} - -function Invoke-CyotDiagnostics { - param([Collections.IDictionary] $State) - - $checks = @( - [pscustomobject]@{ Check = 'PowerShell 7+'; Passed = $PSVersionTable.PSVersion.Major -ge 7; Detail = $PSVersionTable.PSVersion.ToString() }, - [pscustomobject]@{ Check = 'Azure CLI'; Passed = $null -ne (Get-Command az -ErrorAction SilentlyContinue); Detail = 'Required for provisioned Azure endpoints' }, - [pscustomobject]@{ Check = 'Graph authentication module'; Passed = $null -ne (Get-Module -ListAvailable Microsoft.Graph.Authentication); Detail = 'Required for Entra and policy operations' }, - [pscustomobject]@{ Check = 'Graph applications module'; Passed = $null -ne (Get-Module -ListAvailable Microsoft.Graph.Applications); Detail = 'Required for application registration' }, - [pscustomobject]@{ Check = 'Register stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Register -PathType Leaf; Detail = $script:StageScripts.Register }, - [pscustomobject]@{ Check = 'Infrastructure stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Infrastructure -PathType Leaf; Detail = $script:StageScripts.Infrastructure }, - [pscustomobject]@{ Check = 'Deploy stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Deploy -PathType Leaf; Detail = $script:StageScripts.Deploy }, - [pscustomobject]@{ Check = 'Activate stage'; Passed = Test-Path -LiteralPath $script:StageScripts.Activate -PathType Leaf; Detail = $script:StageScripts.Activate }, - [pscustomobject]@{ Check = 'State directory'; Passed = Test-Path -LiteralPath (Split-Path -Parent $StatePath) -PathType Container; Detail = Split-Path -Parent $StatePath } - ) - $checks | Format-Table -AutoSize | Out-Host - Write-CyotEvent -Level INFO -Message "Diagnostics completed: $(@($checks | Where-Object Passed).Count)/$($checks.Count) checks passed." - return $checks -} - -function Show-CyotMenu { - Write-Host @' - -CYOT guided setup - [1] Register or reuse Entra application - [2] Deploy or configure endpoint - [3] Validate deployment and Graph schema - [4] Activate CYOT policy - [A] Run all stages - [R] Resume an interrupted setup - [D] Run diagnostics - [Q] Quit -'@ - $selection = (Read-Host 'Choose an action').Trim().ToUpperInvariant() - switch ($selection) { - '1' { return 'Register' } - '2' { return 'Deploy' } - '3' { return 'Validate' } - '4' { return 'Activate' } - 'A' { return 'All' } - 'R' { return 'Resume' } - 'D' { return 'Diagnostics' } - 'Q' { return 'Quit' } - default { throw "Unknown menu selection '$selection'." } - } -} - -function Get-CyotStagesToRun { - param([string] $SelectedStage, [Collections.IDictionary] $State) - - if ($SelectedStage -eq 'All') { return $script:StageOrder } - if ($SelectedStage -eq 'Resume') { - $remaining = @($script:StageOrder | Where-Object { $State.completedStages -notcontains $_ }) - if ($remaining.Count -eq 0) { return @() } - return $remaining - } - return @($SelectedStage) -} - -Initialize-CyotWorkspace -Write-CyotEvent -Level INFO -Message "CYOT setup started. Package root: $script:PackageRoot" - -try { - $config = Read-CyotConfig - $state = Read-CyotState - $selectedStage = $Stage - if ($Resume) { $selectedStage = 'Resume' } - if ([string]::IsNullOrWhiteSpace($selectedStage)) { - if ($NonInteractive) { $selectedStage = 'All' } - else { $selectedStage = Show-CyotMenu } - } - if ($selectedStage -eq 'Quit') { - Write-CyotEvent -Level INFO -Message 'Setup cancelled before changes were requested.' - return - } - if ($selectedStage -eq 'Diagnostics') { - Invoke-CyotDiagnostics -State $state | Out-Null - return - } - - $stagesToRun = @(Get-CyotStagesToRun -SelectedStage $selectedStage -State $state) - if ($stagesToRun.Count -eq 0) { - Write-CyotEvent -Level INFO -Message 'All stages are already complete. Nothing to resume.' - return - } - foreach ($stageName in $stagesToRun) { - switch ($stageName) { - 'Register' { Invoke-CyotRegister $config $state } - 'Deploy' { Invoke-CyotDeploy $config $state } - 'Validate' { Invoke-CyotValidate $config $state } - 'Activate' { Invoke-CyotActivate $config $state } - } - } - Write-CyotEvent -Level INFO -Message "Requested workflow completed. Completed stages: $($state.completedStages -join ', ')." -} -catch { - Write-CyotEvent -Level ERROR -Message $_.Exception.Message - Write-CyotEvent -Level ERROR -Message "Failure position: $($_.InvocationInfo.PositionMessage)" - Write-Host "Resume after correcting the issue: .\Setup-Cyot.ps1 -Resume -StatePath '$StatePath'" -ForegroundColor Yellow - throw -} -finally { - Write-Host "Event log: $script:EventLogPath" -ForegroundColor DarkGray -} \ No newline at end of file diff --git a/CYOT-Setup/docs/README.md b/CYOT-Setup/docs/README.md deleted file mode 100644 index 841e5d7..0000000 --- a/CYOT-Setup/docs/README.md +++ /dev/null @@ -1,234 +0,0 @@ -> **Produced by:** GitHub Copilot | **Session:** S0915a - -# CYOT guided setup - -Use one entry point to register the Entra application, configure the delivery endpoint, validate the public Microsoft Graph contract, and explicitly activate the Custom OTP (CYOT) policy. - -## Prerequisites - -- PowerShell 7.0 or later -- Azure CLI for an Azure-hosted endpoint -- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and `Microsoft.Graph.Applications` -- An account that can consent to `Application.ReadWrite.All` for registration -- Authentication Policy Administrator for policy activation with `Policy.ReadWrite.AuthenticationMethod` -- Azure permissions to create or configure the selected endpoint resources - -Run local prerequisite checks without signing in: - -```powershell -.\Setup-Cyot.ps1 -Stage Diagnostics -``` - -## Step-by-step runbook - -Use a nonproduction tenant and subscription for the first live test. Run these commands from PowerShell 7 in the `Projects/CYOT-Setup` directory. - -### 1. Install and verify prerequisites - -Install the required Microsoft Graph modules for the current user: - -```powershell -Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery -Force -Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Repository PSGallery -Force -``` - -Install Azure CLI if it isn't already available. On Windows, one supported option is: - -```powershell -winget install --exact --id Microsoft.AzureCLI -``` - -Open a new PowerShell 7 session after installing Azure CLI, then verify the tools and run the package diagnostics: - -```powershell -$PSVersionTable.PSVersion -az version -Get-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -ListAvailable -.\Setup-Cyot.ps1 -Stage Diagnostics -``` - -Continue only when all nine diagnostics pass. - -### 2. Collect the required values - -Have these values ready before starting: - -- Customer Microsoft Entra tenant ID -- Azure subscription ID -- Dedicated test resource group and Azure region -- Globally unique Function App name -- Provider tenant ID and provider API scope ending in `/.default` -- Provider API endpoint and any provider-specific account settings -- Local endpoint ZIP path or a time-limited package URL, when deploying code - -Never put passwords, client secrets, access tokens, private keys, or SAS-bearing URLs in a committed configuration file. - -### 3. Create the customer configuration - -Copy the example outside the repository's tracked files and edit every placeholder: - -```powershell -Copy-Item .\examples\customer-config.example.json "$HOME\cyot-customer-config.json" -notepad "$HOME\cyot-customer-config.json" -``` - -Keep `endpoint.infrastructureMode` set to `Bicep` for the secure infrastructure path. Remove that property to use the original Azure CLI provisioning path. Use a dedicated test resource group so cleanup can't affect unrelated resources. - -Confirm that the JSON is valid: - -```powershell -Get-Content "$HOME\cyot-customer-config.json" -Raw | ConvertFrom-Json | Out-Null -``` - -### 4. Sign in to the test tenant and subscription - -The guided stages can request Microsoft Graph sign-in when needed. Sign in to Azure CLI first and verify the selected context: - -```powershell -$tenantId = '' -$subscriptionId = '' - -az config set core.login_experience_v2=false --only-show-errors -az login --tenant $tenantId -az account set --subscription $subscriptionId -az account show --query '{tenantId:tenantId,subscriptionId:id,subscription:name}' --output table -``` - -Stop if the displayed tenant or subscription isn't the intended test environment. Bicep mode currently requires an interactive Azure user because setup assigns that user Key Vault Secrets Officer. - -### 5. Register the Microsoft Entra application - -```powershell -.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Register -``` - -Review `state/cyot-setup-state.json` and record the `applicationId`. Give that client ID to the selected provider and complete the provider's purchase and onboarding process. Don't continue until the provider supplies its tenant ID, API scope, endpoint, and any required account settings. Add those values to the customer configuration without adding secrets. - -### 6. Deploy and configure the endpoint - -```powershell -.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Deploy -``` - -Approve only the resources shown for the dedicated test environment. If the stage stops, correct the reported issue and continue from the saved state: - -```powershell -.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Resume -``` - -### 7. Verify the deployment - -Inspect the saved identifiers and completed stages: - -```powershell -$state = Get-Content .\state\cyot-setup-state.json -Raw | ConvertFrom-Json -$state | Format-List tenantId, applicationId, subscriptionId, resourceGroup, functionAppName, endpointUrl, completedStages -``` - -For an Azure-hosted endpoint, verify resource and Function App health: - -```powershell -az resource list --resource-group $state.resourceGroup --output table -az functionapp show --resource-group $state.resourceGroup --name $state.functionAppName ` - --query '{name:name,state:state,host:defaultHostName,httpsOnly:httpsOnly}' --output table -``` - -Test the endpoint using the provider or Microsoft test procedure and expected authenticated request shape. A DNS response or generic HTTP response alone doesn't prove OTP delivery works. Confirm that a test request reaches the Function, the provider accepts it, telemetry contains no secrets, and the expected OTP arrives before activation. - -### 8. Validate the public Microsoft Graph contract - -This check resolves endpoint DNS and reads public Graph metadata. It doesn't update tenant policy: - -```powershell -.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Validate -``` - -Review `graphSchemaSupported` and `graphSchemaReason` in `state/cyot-setup-state.json`. If the live public schema doesn't expose the exact CYOT contract, stop. The package deliberately won't guess a preview contract or activate a different authentication method. - -### 9. Review and approve policy activation - -Activation requires Authentication Policy Administrator and delegated `Policy.ReadWrite.AuthenticationMethod`. Run it only after endpoint testing and schema validation succeed: - -```powershell -.\Setup-Cyot.ps1 -ConfigPath "$HOME\cyot-customer-config.json" -Stage Activate -``` - -Review the proposed `cyot` payload at the prompt and type `Yes` only when the tenant ID, application ID, endpoint, and migration choice are correct. Setup saves the previous value under `policy-backups/`, checks for concurrent changes, patches only `cyot`, and verifies the result by reading it back. - -### 10. Roll back or clean up a test - -There is no automatic rollback command. This is intentional because setup can reuse preexisting applications and Azure resources. - -For a policy rollback: - -1. Stop new CYOT testing and identify the exact timestamped backup under `policy-backups/`. -2. Verify its `TenantId`, `PolicyUri`, and `PreviousCyot` values with the tenant administrator. -3. Restore only the `cyot` property through the currently supported Microsoft Graph contract, then read it back and compare it with the backup. -4. If the live schema no longer exposes that contract, don't send a guessed request. Escalate to the owning Microsoft Graph or CYOT support team. - -For Azure cleanup, first confirm the resource group was created solely for this test: - -```powershell -az resource list --resource-group --output table -``` - -Only after reviewing that inventory, delete the dedicated test resource group: - -```powershell -az group delete --name --yes --no-wait -``` - -Don't delete a shared or preexisting resource group. Application registration, provider-side onboarding, certificates, and tenant policy require separate owner-approved cleanup. Keep logs, state, and policy backups until rollback and audit needs are complete; they contain identifiers but shouldn't contain credentials. - -## Guided setup - -```powershell -.\Setup-Cyot.ps1 -``` - -Choose **Run all stages** for the standard flow. The script saves each completed stage to `state/cyot-setup-state.json`. If setup stops, fix the reported issue and continue: - -```powershell -.\Setup-Cyot.ps1 -Resume -``` - -You can also run one stage: - -```powershell -.\Setup-Cyot.ps1 -Stage Register -.\Setup-Cyot.ps1 -Stage Deploy -.\Setup-Cyot.ps1 -Stage Validate -.\Setup-Cyot.ps1 -Stage Activate -``` - -## Configuration - -Copy `examples/customer-config.example.json` to a customer-specific location and replace the placeholders. Don't add passwords, access tokens, private keys, provider credentials, or SAS URLs to the file. - -```powershell -.\Setup-Cyot.ps1 -ConfigPath .\customer-config.json -``` - -Set `endpoint.infrastructureMode` to `Bicep` to provision the Function App, storage account, Key Vault, Log Analytics workspace, Application Insights, managed identity, diagnostics, and role assignments from `infra/main.bicep`. Omit the setting to retain the Azure CLI provisioning path in the endpoint stage. The deployment checks that the configured region supports the required resource providers and Premium Functions SKU before making changes. - -Bicep mode currently requires an interactive Azure user. Setup resolves that user's object ID for the Key Vault Secrets Officer assignment; service-principal deployment isn't supported. The identity-based ZIP deployment path must also be validated in a live customer subscription before production rollout. - -For unattended setup, authenticate Azure CLI and Microsoft Graph in the current process first. Policy activation additionally requires the dedicated approval switch: - -```powershell -.\Setup-Cyot.ps1 -NonInteractive -ConfigPath .\customer-config.json -ApprovePolicyActivation -``` - -Without `-ApprovePolicyActivation`, noninteractive policy activation is rejected. The switch doesn't bypass the live schema check, backup, concurrency check, or post-write verification. - -## Safety model - -- Azure resources and Microsoft Graph are separate control planes. This package coordinates both. -- Registration and deployment are idempotent and can reuse existing resources. -- Validation checks the live public Graph metadata before activation requests policy permissions. -- Activation patches only the supported `cyot` property. -- Activation saves the previous policy value under `policy-backups/` without overwriting existing files. -- State contains resource identifiers and progress only. It doesn't contain credentials or tokens. -- Logs redact common authorization headers and secret-bearing URL query values. - -See [Troubleshooting.md](Troubleshooting.md) for recovery guidance. diff --git a/CYOT-Setup/docs/Troubleshooting.md b/CYOT-Setup/docs/Troubleshooting.md deleted file mode 100644 index cf4a5c6..0000000 --- a/CYOT-Setup/docs/Troubleshooting.md +++ /dev/null @@ -1,55 +0,0 @@ -> **Produced by:** GitHub Copilot | **Session:** S0915a - -# Troubleshooting - -## Start with diagnostics - -```powershell -.\Setup-Cyot.ps1 -Stage Diagnostics -``` - -Review the newest file under `logs/`. The log records stage boundaries and failure locations without intentionally recording credentials. - -## Resume after a failure - -Correct the reported problem, then run: - -```powershell -.\Setup-Cyot.ps1 -Resume -``` - -The orchestrator skips stages listed in `state/cyot-setup-state.json`. Step 2 also supports an internal `StartFromStep` value from 1 through 11 in the configuration file when recovery must continue inside endpoint provisioning. - -## Microsoft Graph sign-in is required - -Interactive runs open the normal delegated sign-in flow. For a noninteractive run, connect in the same PowerShell process with the scopes needed by the stage before launching setup. - -Registration requires `Application.ReadWrite.All`. Activation requires `Policy.ReadWrite.AuthenticationMethod` and the Authentication Policy Administrator role. - -## CYOT isn't exposed by Microsoft Graph - -Validation reads the selected public Graph metadata document. If the exact `authenticationMethodsPolicy.cyot` contract isn't present, activation stops. Don't substitute a guessed property or a different authentication method. Confirm the supported contract with Microsoft before retrying. - -## Azure CLI reports `ValueError: Not a boolean` - -Confirm the current value: - -```powershell -az config get core.login_experience_v2 -``` - -Set a literal lowercase Boolean and retry the Azure sign-in command directly: - -```powershell -az config set core.login_experience_v2=false --only-show-errors -``` - -Also check whether `AZURE_CORE_LOGIN_EXPERIENCE_V2` is set in the process, user, or machine environment. Remove an invalid override before retrying. Step 1 treats its optional Azure CLI sign-in as nonfatal; Step 2 requires a working Azure CLI session when it provisions Azure resources. - -## State is invalid - -The state file uses schema version 1. If it is truncated or manually changed, preserve it for investigation, move it out of `state/`, and rerun the required stages. Never insert credentials into the state file. - -## Policy activation was cancelled - -Cancellation leaves completed registration and endpoint changes in place. No cleanup is automatic. Rerun `-Stage Activate` when the endpoint is tested and the administrator is ready to approve the policy change. \ No newline at end of file diff --git a/CYOT-Setup/examples/customer-config.example.json b/CYOT-Setup/examples/customer-config.example.json deleted file mode 100644 index 747cc68..0000000 --- a/CYOT-Setup/examples/customer-config.example.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "setup": { - "tenantId": "00000000-0000-0000-0000-000000000000", - "subscriptionId": "00000000-0000-0000-0000-000000000000" - }, - "registration": { - "displayName": "Contoso CYOT application", - "skipAzureLogin": false - }, - "endpoint": { - "infrastructureMode": "Bicep", - "environmentName": "prod", - "resourceGroup": "rg-external-phone-provider", - "location": "westus2", - "functionAppName": "contoso-cyot-endpoint", - "planType": "Premium", - "functionRoute": "api/SendOtp", - "providerName": "Replace with provider name", - "providerTenantId": "00000000-0000-0000-0000-000000000000", - "providerScope": "api://provider-application-id/.default", - "providerEndpoint": "https://provider.example.com/api/send", - "resourceTagName": "Purpose", - "resourceTagValue": "Entra - External Phone Provider" - }, - "activation": { - "graphApiVersion": "beta", - "migrated": false - } -} diff --git a/CYOT-Setup/infra/main.bicep b/CYOT-Setup/infra/main.bicep deleted file mode 100644 index 53bd99f..0000000 --- a/CYOT-Setup/infra/main.bicep +++ /dev/null @@ -1,43 +0,0 @@ -targetScope = 'subscription' - -@description('Resource group that contains the CYOT endpoint resources.') -param resourceGroupName string = 'rg-external-phone-provider' - -@description('Azure region for all CYOT endpoint resources.') -param location string - -@minLength(2) -@maxLength(12) -@description('Short environment discriminator used in deterministic resource names.') -param environmentName string = 'prod' - -param resourceTagName string = 'Purpose' -param resourceTagValue string = 'Entra - External Phone Provider' - -@description('Object ID of the operator who may write the endpoint encryption secret.') -param deployerObjectId string - -resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { - name: resourceGroupName - location: location - tags: { - '${resourceTagName}': resourceTagValue - } -} - -module endpoint 'resources.bicep' = { - name: 'cyot-endpoint-${environmentName}' - scope: resourceGroup - params: { - location: location - environmentName: environmentName - resourceTagName: resourceTagName - resourceTagValue: resourceTagValue - deployerObjectId: deployerObjectId - } -} - -output resourceGroupName string = resourceGroup.name -output functionAppName string = endpoint.outputs.functionAppName -output storageAccountName string = endpoint.outputs.storageAccountName -output keyVaultName string = endpoint.outputs.keyVaultName \ No newline at end of file diff --git a/CYOT-Setup/infra/main.parameters.json b/CYOT-Setup/infra/main.parameters.json deleted file mode 100644 index d8d097d..0000000 --- a/CYOT-Setup/infra/main.parameters.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "resourceGroupName": { - "value": "rg-external-phone-provider" - }, - "location": { - "value": "westus2" - }, - "environmentName": { - "value": "prod" - }, - "resourceTagName": { - "value": "Purpose" - }, - "resourceTagValue": { - "value": "Entra - External Phone Provider" - }, - "deployerObjectId": { - "value": "00000000-0000-0000-0000-000000000000" - } - } -} \ No newline at end of file diff --git a/CYOT-Setup/infra/resources.bicep b/CYOT-Setup/infra/resources.bicep deleted file mode 100644 index 46297b1..0000000 --- a/CYOT-Setup/infra/resources.bicep +++ /dev/null @@ -1,241 +0,0 @@ -param location string -param environmentName string -param resourceTagName string -param resourceTagValue string -param deployerObjectId string - -var suffix = uniqueString(subscription().id, resourceGroup().id, environmentName) -var namePrefix = 'cyot-${environmentName}' -var tags = { - '${resourceTagName}': resourceTagValue - workload: 'cyot-endpoint' - environment: environmentName -} -var blobDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') -var queueDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') -var tableDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') -var keyVaultSecretsUserRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') -var keyVaultSecretsOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') -var monitoringMetricsPublisherRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') - -resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: '${namePrefix}-law-${suffix}' - location: location - tags: tags - properties: { - retentionInDays: 30 - features: { - enableLogAccessUsingOnlyResourcePermissions: true - } - } -} - -resource telemetryIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: '${namePrefix}-telemetry-${suffix}' - location: location - tags: tags -} - -resource insights 'Microsoft.Insights/components@2020-02-02' = { - name: '${namePrefix}-appi-${suffix}' - location: location - kind: 'web' - tags: tags - properties: { - Application_Type: 'web' - WorkspaceResourceId: workspace.id - DisableLocalAuth: true - IngestionMode: 'LogAnalytics' - RetentionInDays: 30 - } -} - -resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: 'cyot${take(replace('${environmentName}${suffix}', '-', ''), 20)}' - location: location - tags: tags - sku: { - name: 'Standard_LRS' - } - kind: 'StorageV2' - properties: { - accessTier: 'Hot' - allowBlobPublicAccess: false - allowCrossTenantReplication: false - allowSharedKeyAccess: false - defaultToOAuthAuthentication: true - minimumTlsVersion: 'TLS1_2' - publicNetworkAccess: 'Enabled' - supportsHttpsTrafficOnly: true - } -} - -resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { - name: take('${namePrefix}-kv-${suffix}', 24) - location: location - tags: tags - properties: { - tenantId: tenant().tenantId - enableRbacAuthorization: true - enablePurgeProtection: true - enableSoftDelete: true - softDeleteRetentionInDays: 90 - publicNetworkAccess: 'Enabled' - sku: { - family: 'A' - name: 'standard' - } - } -} - -resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { - name: '${namePrefix}-plan-${suffix}' - location: location - kind: 'linux' - tags: tags - sku: { - name: 'EP1' - tier: 'ElasticPremium' - capacity: 1 - } - properties: { - reserved: true - maximumElasticWorkerCount: 3 - } -} - -resource functionApp 'Microsoft.Web/sites@2024-04-01' = { - name: take('${namePrefix}-func-${suffix}', 60) - location: location - kind: 'functionapp,linux' - tags: tags - identity: { - type: 'SystemAssigned, UserAssigned' - userAssignedIdentities: { - '${telemetryIdentity.id}': {} - } - } - properties: { - serverFarmId: plan.id - httpsOnly: true - keyVaultReferenceIdentity: telemetryIdentity.id - publicNetworkAccess: 'Enabled' - siteConfig: { - alwaysOn: true - ftpsState: 'Disabled' - http20Enabled: true - linuxFxVersion: 'NODE|24' - minTlsVersion: '1.2' - appSettings: [ - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'node' - } - { - name: 'AzureWebJobsStorage__accountName' - value: storage.name - } - { - name: 'AzureWebJobsStorage__credential' - value: 'managedidentity' - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - value: insights.properties.ConnectionString - } - { - name: 'APPLICATIONINSIGHTS_AUTHENTICATION_STRING' - value: 'Authorization=AAD;ClientId=${telemetryIdentity.properties.clientId}' - } - ] - } - } -} - -resource storageBlobRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionApp.id, blobDataContributorRoleId) - scope: storage - properties: { - principalId: functionApp.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: blobDataContributorRoleId - } -} - -resource storageQueueRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionApp.id, queueDataContributorRoleId) - scope: storage - properties: { - principalId: functionApp.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: queueDataContributorRoleId - } -} - -resource storageTableRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storage.id, functionApp.id, tableDataContributorRoleId) - scope: storage - properties: { - principalId: functionApp.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: tableDataContributorRoleId - } -} - -resource vaultReadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(vault.id, telemetryIdentity.id, keyVaultSecretsUserRoleId) - scope: vault - properties: { - principalId: telemetryIdentity.properties.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleId - } -} - -resource vaultWriteRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(vault.id, deployerObjectId, keyVaultSecretsOfficerRoleId) - scope: vault - properties: { - principalId: deployerObjectId - principalType: 'User' - roleDefinitionId: keyVaultSecretsOfficerRoleId - } -} - -resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(insights.id, telemetryIdentity.id, monitoringMetricsPublisherRoleId) - scope: insights - properties: { - principalId: telemetryIdentity.properties.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: monitoringMetricsPublisherRoleId - } -} - -resource functionDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'send-to-log-analytics' - scope: functionApp - properties: { - workspaceId: workspace.id - logs: [ - { - categoryGroup: 'allLogs' - enabled: true - } - ] - metrics: [ - { - category: 'AllMetrics' - enabled: true - } - ] - } -} - -output functionAppName string = functionApp.name -output storageAccountName string = storage.name -output keyVaultName string = vault.name \ No newline at end of file diff --git a/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 b/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 deleted file mode 100644 index bbbe561..0000000 --- a/CYOT-Setup/stages/Deploy-CyotInfrastructure.ps1 +++ /dev/null @@ -1,130 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Deploys the Azure infrastructure used by the CYOT delivery endpoint. - -.DESCRIPTION - Runs deployment preflight checks, creates or updates the resource group through a subscription- - scoped Bicep deployment, and returns the generated resource names to the guided setup script. - This stage never performs Microsoft Graph operations or policy activation. -#> -[CmdletBinding()] -param( - [string] $SubscriptionId, - [string] $ResourceGroup = 'rg-external-phone-provider', - [string] $Location = 'westus2', - [ValidatePattern('^[a-z0-9-]{2,12}$')] - [string] $EnvironmentName = 'prod', - [string] $ResourceTagName = 'Purpose', - [string] $ResourceTagValue = 'Entra - External Phone Provider', - [ValidateSet('Premium')] - [string] $PlanType = 'Premium', - [switch] $NonInteractive -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -function Invoke-CyotAz { - param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) - - $output = & az @Arguments 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Azure CLI failed: az $($Arguments -join ' ')`n$($output -join "`n")" - } - return $output -} - -function Read-CyotRequiredValue { - param([string] $Name, [string] $Value) - - if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value } - if ($NonInteractive) { throw "$Name is required in noninteractive mode." } - $enteredValue = Read-Host $Name - if ([string]::IsNullOrWhiteSpace($enteredValue)) { throw "$Name is required." } - return $enteredValue.Trim() -} - -function Test-CyotProviderLocation { - param([string] $Namespace, [string] $ResourceType, [string] $Region) - - $locations = @((Invoke-CyotAz provider show --namespace $Namespace ` - --query "resourceTypes[?resourceType=='$ResourceType'].locations[]" --output tsv)) - $normalizedRegion = $Region -replace '[^a-zA-Z0-9]', '' - return @($locations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $normalizedRegion }).Count -gt 0 -} - -if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - throw 'Azure CLI is required for Bicep deployment. Install Azure CLI and run az login.' -} - -$SubscriptionId = Read-CyotRequiredValue -Name SubscriptionId -Value $SubscriptionId -$ResourceGroup = Read-CyotRequiredValue -Name ResourceGroup -Value $ResourceGroup -$Location = Read-CyotRequiredValue -Name Location -Value $Location - -$account = ((Invoke-CyotAz account show --output json) -join "`n") | ConvertFrom-Json -if ($account.id -ne $SubscriptionId) { - Invoke-CyotAz account set --subscription $SubscriptionId | Out-Null - $account = ((Invoke-CyotAz account show --output json) -join "`n") | ConvertFrom-Json -} -if ($account.id -ne $SubscriptionId) { throw "Azure CLI did not select subscription '$SubscriptionId'." } - -foreach ($provider in @( - @{ Namespace = 'Microsoft.Web'; Type = 'sites' }, - @{ Namespace = 'Microsoft.Storage'; Type = 'storageAccounts' }, - @{ Namespace = 'Microsoft.KeyVault'; Type = 'vaults' }, - @{ Namespace = 'Microsoft.OperationalInsights'; Type = 'workspaces' }, - @{ Namespace = 'Microsoft.Insights'; Type = 'components' }, - @{ Namespace = 'Microsoft.ManagedIdentity'; Type = 'userAssignedIdentities' })) { - $registrationState = (Invoke-CyotAz provider show --namespace $provider.Namespace ` - --query registrationState --output tsv) -join '' - if ($registrationState -ne 'Registered') { - throw "Resource provider '$($provider.Namespace)' is not registered in subscription '$SubscriptionId'." - } - if (-not (Test-CyotProviderLocation -Namespace $provider.Namespace -ResourceType $provider.Type -Region $Location)) { - throw "Resource type '$($provider.Namespace)/$($provider.Type)' is not available in '$Location'." - } -} - -$premiumLocations = @((Invoke-CyotAz appservice list-locations --sku EP1 --linux-workers-enabled --output tsv)) -$normalizedLocation = $Location -replace '[^a-zA-Z0-9]', '' -if (-not @($premiumLocations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $normalizedLocation }).Count) { - throw "Linux Premium Functions SKU EP1 is not available in '$Location'." -} - -$deployerObjectId = (Invoke-CyotAz ad signed-in-user show --query id --output tsv) -join '' -if ([string]::IsNullOrWhiteSpace($deployerObjectId)) { - throw 'Could not resolve the signed-in Azure user for the Key Vault Secrets Officer assignment.' -} - -$templatePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'infra/main.bicep' -if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { - throw "Bicep template not found: $templatePath" -} - -$deploymentName = "cyot-$EnvironmentName-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" -if (-not $NonInteractive) { - $confirmation = Read-Host "Deploy or update CYOT infrastructure in '$ResourceGroup' ($Location)? [y/N]" - if ($confirmation -notmatch '^(?i)y(?:es)?$') { throw 'Infrastructure deployment was cancelled.' } -} - -$outputs = ((Invoke-CyotAz deployment sub create ` - --name $deploymentName ` - --location $Location ` - --template-file $templatePath ` - --parameters resourceGroupName=$ResourceGroup location=$Location environmentName=$EnvironmentName ` - resourceTagName=$ResourceTagName resourceTagValue=$ResourceTagValue deployerObjectId=$deployerObjectId ` - --query properties.outputs --output json) -join "`n") | ConvertFrom-Json - -[pscustomobject]@{ - Stage = 'Infrastructure' - SubscriptionId = $SubscriptionId - ResourceGroup = $outputs.resourceGroupName.value - Location = $Location - PlanType = 'Premium' - FunctionAppName = $outputs.functionAppName.value - StorageAccountName = $outputs.storageAccountName.value - KeyVaultName = $outputs.keyVaultName.value -} -*** End Patch \ No newline at end of file diff --git a/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 b/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 deleted file mode 100644 index 5fe2684..0000000 --- a/CYOT-Setup/stages/Step1-Register-CyotApplication.ps1 +++ /dev/null @@ -1,568 +0,0 @@ -#Requires -Version 7.0 - -<# -.SYNOPSIS - Stage 1 of 3: register the customer's multi-tenant CYOT application before purchasing a provider. -.DESCRIPTION - Returns only the application (client) ID as a string. Use that ID during Security Store/provider - onboarding, then pass it as -ApplicationId during resource provisioning. - Tenant and object IDs are not included in the result. Sign-in and approval prompts remain. - - When neither -ApplicationId nor -DisplayName is supplied, a guided menu lets you register or find - an application by name, reuse an application by client ID, or exit without making changes. - Supplying either parameter bypasses the menu, and -NonInteractive never displays it. - - This stage creates no Azure resources, secrets, certificates, endpoint bindings or CYOT policy. - It neither buys an offer nor grants access in the provider's tenant. The provider must complete - their onboarding, instantiate this app's service principal in their tenant and grant their API - role. Obtain their tenant ID and API scope for stage 2. - - An existing app is found by explicit client ID, or by an unambiguous display name. Changing an - existing single-tenant app to multi-tenant requires confirmation; no duplicate app is created. - This file is self-contained; it does not load or invoke any other setup script. - Only PowerShell and the Microsoft Graph modules listed above are required. -.PARAMETER TenantId - Customer tenant. Required at Graph sign-in; never inferred from an unrelated Graph session. -.PARAMETER ApplicationId - Optional existing customer application CLIENT ID to reuse. Not the application object ID. -.PARAMETER DisplayName - Name of a new application, or an exact name to reuse when -ApplicationId is absent. - Also accepts -AppName. If neither an application ID nor a name is supplied, asks the customer - to type an app name at the registration step. Supplied names are used without prompting. -.PARAMETER NonInteractive - Allow silent authentication and reuse only; fail rather than prompt for missing inputs or approval. -.PARAMETER LogDirectory - Folder for timestamped event and transcript logs. Defaults to a Logs folder beside this script. -.EXAMPLE - $appId = .\Step1-Register-CyotApplication.ps1 -TenantId - # Type your app name when asked. The result is just the client ID. - # Complete the provider purchase/onboarding using this ID; it must not change in stage 2. -.EXAMPLE - $appId = .\Step1-Register-CyotApplication.ps1 -TenantId -AppName 'Contoso CYOT' -.EXAMPLE - .\Step1-Register-CyotApplication.ps1 -TenantId -ApplicationId -.EXAMPLE - .\Step1-Register-CyotApplication.ps1 -TenantId -AppName 'Contoso CYOT' -LogDirectory C:\Logs\ExternalPhoneProvider - Writes the event log and PowerShell transcript to the customer-selected folder. -.OUTPUTS - System.String. The application (client) ID only. -#> -[CmdletBinding()] -param( - [string] $TenantId, - [string] $ApplicationId, - [Alias('AppName')] - [string] $DisplayName, - [switch] $NonInteractive, - [switch] $SkipAzureLogin, - [string] $LogDirectory = (Join-Path $PSScriptRoot 'Logs') -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$script:TranscriptStarted = $false -$script:LogPath = $null -$script:AzureCliContext = $null -$script:GraphTenantId = $TenantId -$script:GraphAccountName = $null -$script:GraphRequiredScopes = @('Application.ReadWrite.All') - -function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } - -function Write-SetupEvent { - param( - [ValidateSet('INFO', 'WARN', 'ERROR')] - [string] $Level, - [string] $Message - ) - - $entry = "{0:o} [{1}] {2}" -f [DateTimeOffset]::Now, $Level, $Message - Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) - if ($script:LogPath) { Add-Content -LiteralPath $script:LogPath -Value $entry -Encoding utf8 } -} - -function Initialize-SetupLogging { - if (-not (Test-Path -LiteralPath $LogDirectory)) { - New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null - } - $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $script:LogPath = Join-Path $LogDirectory "Step1-Register-CyotApplication-$timestamp.log" - $transcriptPath = Join-Path $LogDirectory "Step1-Register-CyotApplication-$timestamp.transcript.log" - New-Item -ItemType File -Path $script:LogPath -Force | Out-Null - Start-Transcript -LiteralPath $transcriptPath -Force | Out-Null - $script:TranscriptStarted = $true - Write-SetupEvent -Level INFO -Message "Detailed log: $script:LogPath" - Write-SetupEvent -Level INFO -Message "Transcript: $transcriptPath" -} - -function Import-SetupModules { - $requiredModules = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications') - foreach ($moduleName in $requiredModules) { - if (-not (Get-Module -ListAvailable -Name $moduleName)) { - if ($NonInteractive) { - throw "Required module '$moduleName' is not installed. Install it for the current user before using -NonInteractive." - } - Write-Warning "Required module '$moduleName' is not installed." - $answer = [string](Read-Host -Prompt "Install $moduleName from PowerShell Gallery for the current user? [Y/n]") - if ($answer.Trim() -and $answer.Trim() -notmatch '^(?i:y|yes)$') { - throw "Required module '$moduleName' was not installed." - } - Write-SetupEvent -Level INFO -Message "Installing PowerShell module '$moduleName' for the current user." - Install-Module -Name $moduleName -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -ErrorAction Stop - } - Import-Module -Name $moduleName -Force -ErrorAction Stop - $loadedModule = Get-Module -Name $moduleName | Sort-Object Version -Descending | Select-Object -First 1 - Write-SetupEvent -Level INFO -Message "Loaded $moduleName version $($loadedModule.Version)." - } -} - -function Connect-SetupAzureCli { - param([string] $TenantId) - - if ($SkipAzureLogin) { - Write-SetupEvent -Level INFO -Message 'Azure CLI sign-in skipped by request.' - return - } - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-SetupEvent -Level WARN -Message "Azure CLI isn't installed or isn't on PATH. Azure sign-in was skipped because Stage 1 creates no Azure resources." - return - } - if ($NonInteractive) { - $account = az account show --output json 2>$null | ConvertFrom-Json - if (-not $account -or $account.tenantId -ne $TenantId) { - Write-SetupEvent -Level WARN -Message "Azure CLI isn't signed in to tenant '$TenantId'. Azure sign-in was skipped because Stage 1 creates no Azure resources." - return - } - $script:AzureCliContext = $account - return - } - - $answer = [string](Read-Host -Prompt "Sign in to Azure CLI tenant '$TenantId' now for the later provisioning stages? [y/N]") - if ($answer.Trim() -notmatch '^(?i:y|yes)$') { - Write-SetupEvent -Level WARN -Message 'Azure CLI sign-in skipped. Stage 1 can continue, but later stages require Azure authentication.' - return - } - Write-SetupEvent -Level INFO -Message "Starting Azure CLI sign-in for tenant '$TenantId'." - # Azure CLI 2.83 can raise "ValueError: Not a boolean" when an empty environment - # override takes precedence over the valid value in the Azure CLI config file. - $loginExperienceOverride = [Environment]::GetEnvironmentVariable('AZURE_CORE_LOGIN_EXPERIENCE_V2', 'Process') - Remove-Item Env:AZURE_CORE_LOGIN_EXPERIENCE_V2 -ErrorAction SilentlyContinue - try { - az config set core.login_experience_v2=false --only-show-errors - if ($LASTEXITCODE -ne 0) { - Write-SetupEvent -Level WARN -Message 'Azure CLI compatibility configuration failed. Stage 1 will continue because it creates no Azure resources. Run this command before Step 2: az config set core.login_experience_v2=false' - return - } - Write-SetupEvent -Level INFO -Message 'Configured Azure CLI compatibility setting core.login_experience_v2=false.' - - az login --tenant $TenantId --allow-no-subscriptions --output none - $loginExitCode = $LASTEXITCODE - } - finally { - if (-not [string]::IsNullOrWhiteSpace($loginExperienceOverride)) { - $env:AZURE_CORE_LOGIN_EXPERIENCE_V2 = $loginExperienceOverride - } - } - if ($loginExitCode -ne 0) { - Write-SetupEvent -Level WARN -Message "Azure CLI sign-in failed with exit code $loginExitCode. Stage 1 will continue because it creates no Azure resources." - return - } - $script:AzureCliContext = az account show --output json 2>$null | ConvertFrom-Json - Write-SetupEvent -Level INFO -Message 'Azure CLI sign-in completed.' -} - -function Write-SetupFailure { - param([System.Management.Automation.ErrorRecord] $ErrorRecord) - - $details = @( - "Exception: $($ErrorRecord.Exception.GetType().FullName): $($ErrorRecord.Exception.Message)" - "Error ID: $($ErrorRecord.FullyQualifiedErrorId)" - "Category: $($ErrorRecord.CategoryInfo)" - "Position: $($ErrorRecord.InvocationInfo.PositionMessage)" - "Stack trace: $($ErrorRecord.ScriptStackTrace)" - ) -join [Environment]::NewLine - Write-SetupEvent -Level ERROR -Message $details -} - -function Show-ApplicationSelectionMenu { - Write-Host '' - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host ' Select how to register the CYOT application:' -ForegroundColor Cyan - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host '' - Write-Host ' [1] Register a new application or reuse an existing app by name' -ForegroundColor White - Write-Host ' [2] Reuse an existing application by client ID' -ForegroundColor White - Write-Host ' [Q] Exit without making changes' -ForegroundColor White - Write-Host '' - - while ($true) { - $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 2 / Q]')).Trim() - switch -Regex ($choice) { - '^1$' { - Write-SetupEvent -Level INFO -Message "Menu: selected application name lookup or registration." - return 'Name' - } - '^2$' { - Write-SetupEvent -Level INFO -Message 'Menu: selected existing application client ID.' - return 'ApplicationId' - } - '^(?i:q|quit|exit)$' { - Write-SetupEvent -Level INFO -Message 'Menu: selected exit; no setup changes were requested.' - return 'Exit' - } - default { Write-Warning 'Enter 1, 2, or Q.' } - } - } -} - -function Read-SetupValue { - param( - [string] $Name, - $DefaultValue, - [switch] $Required, - [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] - [string] $ValueType = 'String', - [string[]] $Choices = @(), - [string] $Hint, - [switch] $Secret - ) - - $needsInput = $Required -and - ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) - while ($true) { - $value = $DefaultValue - if ($needsInput -and -not $NonInteractive) { - $prompt = "$Name [required]" - if ($Hint) { $prompt += " - $Hint" } - if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } - - if ($Secret) { - $secureValue = Read-Host -Prompt $prompt -AsSecureString - $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) - try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } - finally { - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) - $secureValue.Dispose() - } - } - else { $answer = Read-Host -Prompt $prompt } - if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } - } - - $errorText = $null - if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { - if ($Required) { $errorText = "-$Name is required." } - else { return $null } - } - else { - switch ($ValueType) { - 'Integer' { - $number = 0 - if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { - $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." - } - else { $value = $number } - } - 'Guid' { - $identifier = [Guid]::Empty - if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { - $errorText = "-$Name must be a nonempty GUID." - } - else { $value = $identifier.ToString('D') } - } - 'Scope' { - $resource = "$value" -replace '/\.default$', '' - $resourceId = [Guid]::Empty - $resourceUri = $null - $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) - $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) - if ("$value" -notmatch '/\.default$' -or - ($isGuid -and $resourceId -eq [Guid]::Empty) -or - (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or - $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or - "$value" -match '\s') { - $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." - } - } - 'Boolean' { - if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } - elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } - else { $errorText = "-$Name must be Yes or No." } - } - 'Choice' { - if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } - else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } - } - 'File' { - if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } - } - { $_ -in @('HttpsUrl', 'Url') } { - $parsedUri = $null - if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or - $parsedUri.Scheme -notin @('http', 'https') -or - ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { - $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." - } - } - 'StorageName' { - if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } - } - 'VaultName' { - if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { - $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' - } - } - } - } - - if (-not $errorText) { return $value } - if (-not $needsInput -or $NonInteractive) { throw $errorText } - Write-Warning $errorText - } -} - -function Confirm-SetupAction { - param([string] $Action, [string] $Target, [string] $Details) - - if ($NonInteractive) { - throw "Approval required to $Action '$Target'. Rerun without -NonInteractive; no automatic approval is assumed." - } - Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow - if ($script:AzureCliContext) { - Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" - } - if ($Details) { Write-Host " $Details" } - while ($true) { - $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') - $answer = $answer.Trim() - if ($answer -match '^(?i:y|yes)$') { return } - if (-not $answer -or $answer -match '^(?i:n|no)$') { - throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") - } - Write-Warning 'Enter Yes to approve, or No/empty to stop.' - } -} - -function Test-AuthenticationFailure { - param([string] $Message) - - # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. - return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + - 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + - 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + - '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + - '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + - 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') -} - -function Connect-EndpointGraph { - param([switch] $Reconnect, [string[]] $Scopes) - - if ($PSBoundParameters.ContainsKey('Scopes')) { - if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { - throw 'Graph authentication requires at least one nonempty scope.' - } - $script:GraphRequiredScopes = $Scopes - } - - $context = Get-MgContext -ErrorAction Stop - $canReuse = $context -and $context.AuthType -eq 'Delegated' -and - $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and - $context.Environment -eq 'Global' -and - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and - (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) - - if ($Reconnect -or -not $canReuse) { - if ($NonInteractive) { - throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." - } - $connectParameters = @{ - Scopes = $script:GraphRequiredScopes - ContextScope = 'Process' - Environment = 'Global' - NoWelcome = $true - ErrorAction = 'Stop' - } - if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } - Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow - Connect-MgGraph @connectParameters | Out-Null - $context = Get-MgContext -ErrorAction Stop - } - - if (-not $context -or $context.AuthType -ne 'Delegated' -or - $context.Environment -ne 'Global' -or - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or - ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or - ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { - throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' - } - $script:GraphTenantId = $context.TenantId - $script:GraphAccountName = $context.Account -} - -function Invoke-EndpointGraph { - param([scriptblock] $Operation) - - try { - & $Operation - } - catch { - $exception = $_.Exception - $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) - while ($exception) { - if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or - ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or - ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { - $authenticationFailure = $true - } - $exception = $exception.InnerException - } - if (-not $authenticationFailure) { throw } - Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow - Connect-EndpointGraph -Reconnect - & $Operation - } -} - -function Get-CyotApplication { - param([string] $ApplicationId, [switch] $RequireMultiTenant) - - $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid - $matches = @(Invoke-EndpointGraph { - Get-MgApplication -Filter "appId eq '$ApplicationId'" -Property Id, AppId -All -ErrorAction Stop - }) - if ($matches.Count -ne 1) { - throw "Expected exactly one app registration with client ID '$ApplicationId' in tenant '$script:GraphTenantId'; found $($matches.Count). Complete app registration in the correct tenant. No replacement app will be created." - } - $application = Invoke-EndpointGraph { - Get-MgApplication -ApplicationId $matches[0].Id ` - -Property Id, AppId, DisplayName, SignInAudience, Api, IdentifierUris, KeyCredentials, TokenEncryptionKeyId ` - -ErrorAction Stop - } - if (-not $application -or $application.AppId -ne $ApplicationId) { - throw 'Graph did not return the requested application.' - } - if ($RequireMultiTenant -and $application.SignInAudience -ne 'AzureADMultipleOrgs') { - throw "Application '$ApplicationId' is not multi-tenant. Review and approve that change in the app-registration stage before provisioning." - } - return $application -} - -function Ensure-CyotEndpointServicePrincipal { - param([string] $ApplicationId) - - $principals = @(Invoke-EndpointGraph { - Get-MgServicePrincipal -Filter "appId eq '$ApplicationId'" -All -ErrorAction Stop - }) - if ($principals.Count -gt 1) { throw "Multiple service principals match application '$ApplicationId'." } - if (-not $principals.Count) { - Confirm-SetupAction -Action 'create endpoint service principal' -Target $ApplicationId ` - -Details "Tenant: $script:GraphTenantId. No permission is granted to the provider or Microsoft." - $principal = Invoke-EndpointGraph { New-MgServicePrincipal -AppId $ApplicationId -ErrorAction Stop } - } - else { $principal = $principals[0] } - if (-not $principal -or -not $principal.Id) { throw 'Graph did not return an endpoint service-principal ID.' } - if ($principal.AppRoleAssignmentRequired) { - Confirm-SetupAction -Action 'remove endpoint app-role assignment requirement' -Target $principal.Id ` - -Details 'Microsoft EPP uses its pre-authorized caller identity. Easy Auth must still restrict callers to the Microsoft EPP application.' - Invoke-EndpointGraph { - Update-MgServicePrincipal -ServicePrincipalId $principal.Id -AppRoleAssignmentRequired:$false -ErrorAction Stop - } | Out-Null - } - return $principal -} - - -try { - Initialize-SetupLogging - if (-not $NonInteractive -and - [string]::IsNullOrWhiteSpace($ApplicationId) -and - [string]::IsNullOrWhiteSpace($DisplayName)) { - $applicationSelection = Show-ApplicationSelectionMenu - if ($applicationSelection -eq 'Exit') { return } - if ($applicationSelection -eq 'ApplicationId') { - $ApplicationId = Read-SetupValue -Name ApplicationId -Required -ValueType Guid ` - -Hint 'Use the application (client) ID, not the object ID' - } - } - - Write-Step 'Stage 1: preparing prerequisites' - Import-SetupModules - - Write-Step 'Stage 1: registering the customer application' - $tenantGuid = [Guid]::Empty - $tenantIdIsValid = [Guid]::TryParse($TenantId, [ref] $tenantGuid) -and $tenantGuid -ne [Guid]::Empty - if (-not $tenantIdIsValid) { - if ($NonInteractive) { - throw '-TenantId must be supplied as a nonempty GUID when using -NonInteractive.' - } - if (-not [string]::IsNullOrWhiteSpace($TenantId)) { - Write-Warning "The supplied -TenantId '$TenantId' is not a valid nonempty GUID." - } - Write-Host ' Enter the Microsoft Entra tenant ID where the CYOT application will be registered.' -ForegroundColor Yellow - while ($true) { - $tenantAnswer = ([string](Read-Host -Prompt 'TenantId [required] - use the Directory (tenant) ID')).Trim() - $tenantGuid = [Guid]::Empty - if ([Guid]::TryParse($tenantAnswer, [ref] $tenantGuid) -and $tenantGuid -ne [Guid]::Empty) { - break - } - Write-Warning '-TenantId must be a nonempty GUID.' - } - } - $TenantId = $tenantGuid.ToString('D') - $script:GraphTenantId = $TenantId - Connect-SetupAzureCli -TenantId $TenantId - Write-Host " Entra sign-in: authenticate to tenant '$TenantId' when prompted." -ForegroundColor Yellow - Connect-EndpointGraph -Scopes @('Application.ReadWrite.All') - Write-SetupEvent -Level INFO -Message "Microsoft Entra sign-in completed for tenant '$script:GraphTenantId' as '$script:GraphAccountName'." - - $application = $null - if ([string]::IsNullOrWhiteSpace($ApplicationId)) { - $DisplayName = Read-SetupValue -Name AppName -DefaultValue $DisplayName -Required - $matches = @(Invoke-EndpointGraph { - Get-MgApplication -Filter "displayName eq '$($DisplayName.Replace("'", "''"))'" -Property Id, AppId -All -ErrorAction Stop - }) - if ($matches.Count -gt 1) { - throw "Multiple applications use display name '$DisplayName'. Supply -ApplicationId to choose one; no new app was created." - } - if ($matches.Count -eq 1) { $ApplicationId = $matches[0].AppId } - else { - Confirm-SetupAction -Action 'create multi-tenant CYOT application' -Target $DisplayName ` - -Details "Customer tenant: $TenantId. This client ID will be given to the provider. No secrets or API permissions are created." - $createdApplication = Invoke-EndpointGraph { - New-MgApplication -DisplayName $DisplayName -SignInAudience AzureADMultipleOrgs ` - -Api @{ RequestedAccessTokenVersion = 1 } -ErrorAction Stop - } - if (-not $createdApplication -or -not $createdApplication.Id -or -not $createdApplication.AppId) { - throw 'Graph did not return the new application client ID.' - } - $ApplicationId = $createdApplication.AppId - $application = $createdApplication - } - } - - if (-not $application) { $application = Get-CyotApplication -ApplicationId $ApplicationId } - if ($application.SignInAudience -ne 'AzureADMultipleOrgs') { - if ($application.SignInAudience -ne 'AzureADMyOrg') { - throw 'This app is not an organizational single- or multi-tenant app. Select a dedicated CYOT application.' - } - Confirm-SetupAction -Action 'make existing CYOT application multi-tenant' -Target $ApplicationId ` - -Details 'Other organizational tenants will be able to instantiate its service principal. The application client ID and existing credentials remain unchanged.' - Invoke-EndpointGraph { - Update-MgApplication -ApplicationId $application.Id -SignInAudience AzureADMultipleOrgs -ErrorAction Stop - } | Out-Null - $application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant - } - Ensure-CyotEndpointServicePrincipal -ApplicationId $application.AppId | Out-Null - Write-SetupEvent -Level INFO -Message "Stage 1 completed successfully for application '$($application.AppId)'." - Write-Host "`nApplication ID: $($application.AppId)" -ForegroundColor Green - Write-Host 'Save this application ID. You will need it for Step 2 and later configuration steps.' -ForegroundColor Yellow - [string] $application.AppId -} -catch { - Write-SetupFailure -ErrorRecord $_ - throw -} -finally { - if ($script:TranscriptStarted) { Stop-Transcript | Out-Null } -} diff --git a/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 b/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 deleted file mode 100644 index fb5d6f4..0000000 --- a/CYOT-Setup/stages/Step2-Setup-ExternalPhoneProvider.ps1 +++ /dev/null @@ -1,2087 +0,0 @@ -#Requires -Version 7.0 -#Requires -Modules Microsoft.Graph.Applications, Microsoft.Graph.Authentication - -<# -.SYNOPSIS - Stage 2 of 3: configure the delivery endpoint for the CYOT application registered in stage 1. - -.DESCRIPTION - Run stage 1 first, then complete Security Store/provider onboarding with the application (client) - ID it returns. This script takes that SAME -ApplicationId and validates the existing multi-tenant - application in the customer tenant. It never creates a replacement application or selects one by - display name. - - Choose one of two endpoint modes: provide -FunctionAppName to provision an Azure Function and its - supporting resources, or provide -EndpointUrl to configure an HTTPS endpoint you already operate. - The script configures the application identifier URI, encryption certificate, endpoint metadata, - managed identities, provider settings, and telemetry required by the selected mode. - - When neither endpoint parameter is supplied, a guided menu lets you choose Azure Function - provisioning, an existing HTTPS endpoint, or exit without making changes. Supplying either - endpoint parameter bypasses the menu, and -NonInteractive never displays it. - - For a provisioned Function, adds a user-assigned managed identity and federated identity credential - to authenticate as the customer's multi-tenant app to the provider. Obtain -ProviderTenantId and - -ProviderScope from the provider after purchase. Their API role assignment is a provider-side step, - not something this script grants. The deployed package must implement the EPP_* outbound settings. - The system-assigned identity continues to handle storage and Key Vault. - - Policy activation is a SEPARATE stage after validating the deployed endpoint. This stage does not - enable CYOT or change any Graph policy. This file is self-contained; it does not load or invoke - any other setup script. Azure CLI and the Microsoft Graph modules are still required. - - Microsoft's application is first party and pre-authorized. Nothing is consented, and no - permission is granted to Microsoft anywhere in this script. - - Safe to re-run: existing objects are reused rather than duplicated. - - Azure CLI prepares separate ARM, Microsoft Graph and Key Vault tokens before provisioning. - Azure CLI and the Graph PowerShell SDK refresh expired access tokens using their own caches. - Authentication failures trigger one recovery attempt, with interactive sign-in only when silent - refresh is no longer possible. Access tokens are never printed or copied between the two clients. - - Required settings are requested only when the step that needs them is reached. Supplied values - and defaults are used without prompting; omitted optional settings are not requested. Empty input - for a missing required setting prompts again. Provider settings are collected when configuring - the Function, not before provisioning. Every new resource still requires an explicit Yes. Empty - input or No at a creation confirmation stops the script without deleting anything already created. - Existing resources are reused without a creation confirmation. - New telemetry workspaces are created explicitly in the same resource group. A soft-deleted vault - can be recovered with confirmation; the script never purges vaults or performs subscription cleanup. - - Each run writes a timestamped event log and PowerShell transcript under the script's Logs folder, - or under -LogDirectory when supplied. Failure entries include the exception type, error ID, - category, source position, and script stack trace. The script redacts common credential-bearing - values and does not intentionally log access tokens, SAS signatures, private keys, provider - credentials, or Function app-setting values. - -.PARAMETER TenantId - Optional tenant for sign-in. Inferred from the selected Azure subscription when provisioning. - Also pins the Graph PowerShell connection so app registrations are created in the same tenant. - -.PARAMETER ApplicationId - Required application CLIENT ID string from app registration, not the object ID. - The existing app must be multi-tenant and registered in the customer tenant. - -.PARAMETER ProviderTenantId - Provider's tenant, which issues the outbound provider API token. Not the customer/app tenant. - Requested when configuring the Function if missing. - -.PARAMETER ProviderScope - Provider API App ID URI or application ID followed by /.default. This is NOT the customer app ID. - -.PARAMETER OutboundIdentityName - Optional name for the user-assigned managed identity. Defaults to -outbound. - -.PARAMETER LogDirectory - Folder for timestamped event and transcript logs. Defaults to a Logs folder beside this script. - -.PARAMETER StartFromStep - Resume at a numbered step from 1 through 11. Steps before the selected step are verified and - their required state is reconstructed without repeating their changes. If a prerequisite from a - skipped step is missing, the script stops and tells you which earlier step to resume from. - -.PARAMETER DisplayName - Retained for command-line compatibility. Stage 2 selects the existing app only by -ApplicationId. - -.PARAMETER NonInteractive - Do not prompt for settings, creation approval or sign-in. Defaults and supplied values are used. - The script stops at the first step that needs a missing required input, resource-creation approval, - or interactive sign-in. Cached credentials may still refresh silently. - -.PARAMETER UseWindowsBroker - Use Azure CLI's configured Windows authentication broker rather than the browser-login - workaround for older CLI versions. Use this if your tenant requires broker-based authentication. - By default the workaround applies only while this script invokes Azure CLI; persistent CLI - configuration and the caller's environment are not changed. - -.PARAMETER FunctionAppName - Globally unique name for the Azure Function to create. If neither this nor -EndpointUrl is - supplied, the guided flow asks which endpoint mode to use and requests the corresponding value. - -.PARAMETER EndpointUrl - An HTTPS endpoint you already operate, for example https://otp.contoso.com/api/SendOtp. - Supplying this skips Azure provisioning entirely. - -.PARAMETER ZipUrl - Optional. URL of a zip package to deploy, typically the reference endpoint Microsoft publishes to - blob storage. Downloaded and pushed to the Function. - -.PARAMETER ZipPath - Optional. A local zip package, used in preference to -ZipUrl. - -.PARAMETER PlanType - FlexConsumption (default) keeps one instance always ready. Premium (EP1) is the fallback where - Flex Consumption is unavailable. Plain Consumption is deliberately not offered: its cold start - exceeds the 3.2 s delivery budget. - -.PARAMETER KeyVaultName - Key Vault to hold the encryption private key. Created if absent. Defaults to a name derived from - the Function name. The key is stored as a secret and the Function reads it through a Key Vault - reference, so the private key never appears in app settings. A matching soft-deleted vault in the - same resource group is offered for recovery, retaining its keys and secrets rather than purging it. - -.PARAMETER ResourceTagName - Tag name applied to every resource this script creates. Defaults to 'Purpose'. - -.PARAMETER ResourceTagValue - Tag value applied to every resource this script creates. Defaults to - 'Entra - External Phone Provider', which is what makes these resources findable as a set. - -.PARAMETER CertificatePath - Optional. An existing .cer/.crt public certificate to publish as the encryption key. When - omitted a self-signed certificate is created in CurrentUser\My and exported next to this script. - -.PARAMETER ProviderName - Telephony provider to use, chosen from the supported set. Written to EPP_PROVIDER_NAME. - Requested at the Function configuration step if omitted. - -.PARAMETER ProviderEndpoint - The provider's API endpoint. Supplied by the onboarding experience from the security store. - -.PARAMETER ProviderTimeoutMs - Per-call timeout against the provider, in milliseconds. From the security store. - -.PARAMETER ProviderRetryIntervalMs - Delay between provider retries, in milliseconds. From the security store. - -.PARAMETER ProviderAccountName - Your account name with the provider. Supplied by you. - -.PARAMETER NoEasyAuth - Skips App Service Authentication and leaves token validation to your function code. By default - Easy Auth is configured to reject anything that is not a Microsoft token, before your code - runs. - -.EXAMPLE - .\Step2-Setup-ExternalPhoneProvider.ps1 - Asks which endpoint to use, then requests missing required values only as each step needs them. - Defaults and optional settings do not prompt; resource creation still needs Yes. - -.EXAMPLE - .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -Location westus2 - -.EXAMPLE - .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -ZipPath .\SendOtp.zip -ProviderTenantId -ProviderScope api://provider-api/.default - -.EXAMPLE - .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -EndpointUrl https://otp.contoso.com/api/SendOtp -TenantId - -.EXAMPLE - .\Step2-Setup-ExternalPhoneProvider.ps1 -ApplicationId -FunctionAppName contoso-otp -StartFromStep 9 -LogDirectory C:\Logs\ExternalPhoneProvider - Reconstructs existing state, resumes with Function configuration and deployment, and writes logs - to the customer-selected folder. -#> - -[CmdletBinding()] -param( - [string] $FunctionAppName, - - [string] $EndpointUrl, - - [string] $SubscriptionId, - - [string] $ResourceGroup = 'rg-external-phone-provider', - - [string] $Location = 'westus2', - - [string] $StorageAccountName, - - [string] $KeyVaultName, - - [string] $ResourceTagName = 'Purpose', - - [string] $ResourceTagValue = 'Entra - External Phone Provider', - - [ValidateSet('FlexConsumption', 'Premium')] - [string] $PlanType = 'FlexConsumption', - - [string] $ZipUrl, - - [string] $ZipPath, - - [string] $FunctionRoute = 'api/SendOtp', - - [string] $DisplayName = 'Contoso MFA Telephony Endpoint', - - [string] $CertificatePath, - - [string] $ProviderName, - - [string] $ProviderEndpoint, - - [int] $ProviderTimeoutMs, - - [int] $ProviderRetryIntervalMs, - - [string] $ProviderAccountName, - - [switch] $NoEasyAuth, - - [string] $TenantId, - - [switch] $NonInteractive, - - [switch] $UseWindowsBroker, - - [string] $ApplicationId, - - [string] $ProviderTenantId, - - [string] $ProviderScope, - - [string] $OutboundIdentityName, - - [string] $LogDirectory = (Join-Path $PSScriptRoot 'Logs'), - - [ValidateRange(1, 11)] - [int] $StartFromStep = 1 -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -# Microsoft's first-party application. It reads your published key and calls your endpoint. You do -# not grant it anything: it is pre-authorized, and this value is the same in all public clouds. -$MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13' - -# The reference endpoint implementation Microsoft publishes to blob storage. Deployed when neither -# -ZipPath nor -ZipUrl is supplied. -# -# The container is private, so this URL needs a read SAS appended before it will download. Pass the -# full URL including the SAS as -ZipUrl, or replace this value with one. The token is deliberately -# not stored here: this script is handed to customers, and a SAS in it is a credential in a document. -$ReferencePackageUrl = 'https://cyote2ecodesample.blob.core.windows.net/packages/external-phone-provider-endpoint.zip' - -# TODO: replace with the published provider list before release. -# ProviderName is a selection rather than free text, so it is validated here instead of with a -# ValidateSet attribute: the list changes independently of this script and is easier to maintain in -# one place. An empty list disables the check. -$SupportedProviders = @() - -$script:AzureCliContext = $null -$script:GraphTenantId = $TenantId -$script:GraphAccountName = $null -$script:GraphRequiredScopes = @('Application.ReadWrite.All') -$script:TranscriptStarted = $false -$script:EventLogPath = $null -$script:TranscriptPath = $null -$script:AzureCliResources = @{ - Arm = 'https://management.core.windows.net/' - Graph = 'https://graph.microsoft.com' - KeyVault = 'https://vault.azure.net' -} - -function Set-FunctionAppSettings { - <# - Merges app settings into the Function. - - Deliberately a read-merge-PUT against ARM rather than 'az functionapp config appsettings - set'. A Key Vault reference contains parentheses, az.cmd is a batch wrapper, and cmd.exe - treats those as metacharacters -- passing one inline mangles the command line. Routing the - value through a request body file means no shell ever parses it. - - The ARM appsettings endpoint replaces rather than merges, so existing settings are read and - carried forward. Dropping that step would silently wipe the platform's own settings. - #> - param( - [string] $Name, - [string] $ResourceGroup, - [string] $SubscriptionId, - [hashtable] $Settings - ) - - $existingJson = (Invoke-Az functionapp config appsettings list ` - --name $Name --resource-group $ResourceGroup -o json --only-show-errors) -join "`n" - - $merged = @{} - foreach ($item in ($existingJson | ConvertFrom-Json)) { - $merged[$item.name] = $item.value - } - foreach ($key in $Settings.Keys) { - $merged[$key] = $Settings[$key] - } - - $bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "epp-appsettings-$([Guid]::NewGuid()).json" - [System.IO.File]::WriteAllText( - $bodyFile, - (@{ properties = $merged } | ConvertTo-Json -Depth 5), - [System.Text.UTF8Encoding]::new($false)) - - try { - Invoke-Az rest --method put ` - --url ("https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup" + - "/providers/Microsoft.Web/sites/$Name/config/appsettings?api-version=2022-03-01") ` - --body "@$bodyFile" ` - --headers 'Content-Type=application/json' | Out-Null - } - finally { - Remove-Item $bodyFile -Force -ErrorAction SilentlyContinue - } - - return $merged.Count -} - -function Protect-SetupLogText { - param([AllowEmptyString()][string] $Text) - - if ([string]::IsNullOrEmpty($Text)) { return $Text } - $redacted = $Text -replace '(?i)(Authorization\s*[:=]\s*Bearer\s+)[^\s,;]+', '$1[REDACTED]' - $redacted = $redacted -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' - return $redacted -} - -function Write-SetupEvent { - param( - [ValidateSet('INFO', 'WARN', 'ERROR')] - [string] $Level, - [string] $Message, - [switch] $NoConsole - ) - - $safeMessage = Protect-SetupLogText -Text $Message - $entry = "{0:o} [{1}] {2}" -f [DateTimeOffset]::Now, $Level, $safeMessage - if (-not $NoConsole) { - Write-Host $entry -ForegroundColor ($Level -eq 'ERROR' ? 'Red' : ($Level -eq 'WARN' ? 'Yellow' : 'DarkGray')) - } - if ($script:EventLogPath) { - Add-Content -LiteralPath $script:EventLogPath -Value $entry -Encoding utf8 - } -} - -function Initialize-SetupLogging { - if (-not (Test-Path -LiteralPath $LogDirectory)) { - New-Item -ItemType Directory -Path $LogDirectory -Force | Out-Null - } - - $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $script:EventLogPath = Join-Path $LogDirectory "Step2-Setup-ExternalPhoneProvider-$timestamp.log" - $script:TranscriptPath = Join-Path $LogDirectory "Step2-Setup-ExternalPhoneProvider-$timestamp.transcript.log" - New-Item -ItemType File -Path $script:EventLogPath -Force | Out-Null - Start-Transcript -LiteralPath $script:TranscriptPath -Force | Out-Null - $script:TranscriptStarted = $true - Write-SetupEvent -Level INFO -Message 'Stage 2 setup started.' - Write-SetupEvent -Level INFO -Message "Event log: $script:EventLogPath" - Write-SetupEvent -Level INFO -Message "Transcript: $script:TranscriptPath" -} - -function Write-SetupFailure { - param([System.Management.Automation.ErrorRecord] $ErrorRecord) - - $details = @( - "Exception: $($ErrorRecord.Exception.GetType().FullName): $($ErrorRecord.Exception.Message)" - "Error ID: $($ErrorRecord.FullyQualifiedErrorId)" - "Category: $($ErrorRecord.CategoryInfo)" - "Position: $($ErrorRecord.InvocationInfo.PositionMessage)" - "Stack trace: $($ErrorRecord.ScriptStackTrace)" - ) -join [Environment]::NewLine - Write-SetupEvent -Level ERROR -Message $details -} - -function Write-Step { - param([string] $Text) - - Write-Host "`n=== $Text ===" -ForegroundColor Cyan - Write-SetupEvent -Level INFO -Message "Step: $Text" -NoConsole -} - -function Get-DefaultStorageAccountName { - param([string] $FunctionName) - $stem = ($FunctionName -replace '[^a-zA-Z0-9]', '').ToLowerInvariant() - if ($stem.Length -gt 18) { $stem = $stem.Substring(0, 18) } - return "${stem}eppsa" -} - -function Get-DefaultKeyVaultName { - param([string] $FunctionName) - $stem = ($FunctionName -replace '[^a-zA-Z0-9-]', '').ToLowerInvariant() - if ($stem.Length -gt 20) { $stem = $stem.Substring(0, 20) } - return "kv-$stem" -} - -function Read-SetupValue { - param( - [string] $Name, - $DefaultValue, - [switch] $Required, - [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] - [string] $ValueType = 'String', - [string[]] $Choices = @(), - [string] $Hint, - [switch] $Secret - ) - - $needsInput = $Required -and - ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) - while ($true) { - $value = $DefaultValue - if ($needsInput -and -not $NonInteractive) { - $prompt = "$Name [required]" - if ($Hint) { $prompt += " - $Hint" } - if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } - - if ($Secret) { - $secureValue = Read-Host -Prompt $prompt -AsSecureString - $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) - try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } - finally { - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) - $secureValue.Dispose() - } - } - else { $answer = Read-Host -Prompt $prompt } - if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } - } - - $errorText = $null - if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { - if ($Required) { $errorText = "-$Name is required." } - else { return $null } - } - else { - switch ($ValueType) { - 'Integer' { - $number = 0 - if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { - $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." - } - else { $value = $number } - } - 'Guid' { - $identifier = [Guid]::Empty - if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { - $errorText = "-$Name must be a nonempty GUID." - } - else { $value = $identifier.ToString('D') } - } - 'Scope' { - $resource = "$value" -replace '/\.default$', '' - $resourceId = [Guid]::Empty - $resourceUri = $null - $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) - $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) - if ("$value" -notmatch '/\.default$' -or - ($isGuid -and $resourceId -eq [Guid]::Empty) -or - (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or - $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or - "$value" -match '\s') { - $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." - } - } - 'Boolean' { - if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } - elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } - else { $errorText = "-$Name must be Yes or No." } - } - 'Choice' { - if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } - else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } - } - 'File' { - if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } - } - { $_ -in @('HttpsUrl', 'Url') } { - $parsedUri = $null - if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or - $parsedUri.Scheme -notin @('http', 'https') -or - ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { - $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." - } - } - 'StorageName' { - if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } - } - 'VaultName' { - if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { - $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' - } - } - } - } - - if (-not $errorText) { return $value } - if (-not $needsInput -or $NonInteractive) { throw $errorText } - Write-Warning $errorText - } -} - -function Show-EndpointSelectionMenu { - Write-Host '' - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host ' Select the delivery endpoint to configure:' -ForegroundColor Cyan - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host '' - Write-Host ' [1] Provision a new Azure Function and supporting resources' -ForegroundColor White - Write-Host ' [2] Configure an existing HTTPS endpoint' -ForegroundColor White - Write-Host ' [Q] Exit without making changes' -ForegroundColor White - Write-Host '' - - while ($true) { - $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 2 / Q]')).Trim() - switch -Regex ($choice) { - '^1$' { - Write-SetupEvent -Level INFO -Message 'Menu: selected Azure Function provisioning.' -NoConsole - return 'Function' - } - '^2$' { - Write-SetupEvent -Level INFO -Message 'Menu: selected existing HTTPS endpoint.' -NoConsole - return 'Existing' - } - '^(?i:q|quit|exit)$' { - Write-SetupEvent -Level INFO -Message 'Menu: selected exit; no setup changes were requested.' -NoConsole - return 'Exit' - } - default { Write-Warning 'Enter 1, 2, or Q.' } - } - } -} - -function Show-ResumeSelectionMenu { - Write-Host '' - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host ' Select where Stage 2 should start:' -ForegroundColor Cyan - Write-Host ('=' * 78) -ForegroundColor DarkCyan - Write-Host '' - Write-Host ' [1] Full run, including Azure Function provisioning' -ForegroundColor White - Write-Host ' [6] Resume application configuration and key publication' -ForegroundColor White - Write-Host ' [9] Resume Function security, settings, and deployment' -ForegroundColor White - Write-Host ' [10] Resume resource tagging and completion checks' -ForegroundColor White - Write-Host '' - - while ($true) { - $choice = ([string](Read-Host -Prompt ' Enter your choice [1 / 6 / 9 / 10]')).Trim() - if ($choice -in @('1', '6', '9', '10')) { return [int]$choice } - Write-Warning 'Enter 1, 6, 9, or 10.' - } -} - -function Resolve-EndpointParameters { - param([string] $FunctionName, [string] $ExistingEndpoint) - - $useExistingEndpoint = -not [string]::IsNullOrWhiteSpace($ExistingEndpoint) - if (-not $useExistingEndpoint -and [string]::IsNullOrWhiteSpace($FunctionName)) { - if ($NonInteractive) { throw 'Supply -FunctionAppName or -EndpointUrl when using -NonInteractive.' } - $mode = Show-EndpointSelectionMenu - if ($mode -eq 'Exit') { - return [PSCustomObject]@{ - FunctionAppName = $null - EndpointUrl = $null - ProvisionFunction = $false - ExitRequested = $true - } - } - $useExistingEndpoint = $mode -eq 'Existing' - } - - if ($useExistingEndpoint) { - $ExistingEndpoint = Read-SetupValue -Name EndpointUrl -DefaultValue $ExistingEndpoint -Required -ValueType HttpsUrl - } - else { - $FunctionName = Read-SetupValue -Name FunctionAppName -DefaultValue $FunctionName -Required - $ExistingEndpoint = $null - } - return [PSCustomObject]@{ - FunctionAppName = $FunctionName - EndpointUrl = $ExistingEndpoint - ProvisionFunction = -not $useExistingEndpoint - ExitRequested = $false - } -} - -function Get-ProviderAppSettings { - param( - [string] $Name, - [string] $Endpoint, - [Nullable[int]] $TimeoutMs, - [Nullable[int]] $RetryIntervalMs, - [string] $AccountName - ) - - if ($SupportedProviders.Count) { - $Name = Read-SetupValue -Name ProviderName -DefaultValue $Name -Required -ValueType Choice -Choices $SupportedProviders - } - else { $Name = Read-SetupValue -Name ProviderName -DefaultValue $Name -Required } - $Endpoint = Read-SetupValue -Name ProviderEndpoint -DefaultValue $Endpoint -Required -ValueType Url - $TimeoutMs = Read-SetupValue -Name ProviderTimeoutMs -DefaultValue $TimeoutMs -Required -ValueType Integer - $RetryIntervalMs = Read-SetupValue -Name ProviderRetryIntervalMs -DefaultValue $RetryIntervalMs -Required -ValueType Integer - $AccountName = Read-SetupValue -Name ProviderAccountName -DefaultValue $AccountName -Required - - if ($Endpoint -notmatch '^https://') { - Write-Host ' Provider endpoint is not HTTPS. The passcode leaves your Function in clear text.' -ForegroundColor Red - } - if ($TimeoutMs -ge 3200) { - Write-Host " Provider timeout is $TimeoutMs ms, at or over Microsoft's 3.2 s budget." -ForegroundColor Yellow - Write-Host ' Safe only if you respond 2xx before calling the provider. A synchronous call will time out.' - } - return @{ - EPP_PROVIDER_NAME = $Name - EPP_PROVIDER_ENDPOINT = $Endpoint - EPP_PROVIDER_TIMEOUT_MS = "$TimeoutMs" - EPP_PROVIDER_RETRY_INTERVAL_MS = "$RetryIntervalMs" - EPP_PROVIDER_ACCOUNT_NAME = $AccountName - } -} - -function Confirm-SetupAction { - param([string] $Action, [string] $Target, [string] $Details) - - if ($NonInteractive) { - throw "Approval required to $Action '$Target'. Rerun without -NonInteractive; no automatic approval is assumed." - } - Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow - if ($script:AzureCliContext) { - Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" - } - if ($Details) { Write-Host " $Details" } - while ($true) { - $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') - $answer = $answer.Trim() - if ($answer -match '^(?i:y|yes)$') { return } - if (-not $answer -or $answer -match '^(?i:n|no)$') { - throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") - } - Write-Warning 'Enter Yes to approve, or No/empty to stop.' - } -} - -function Test-AuthenticationFailure { - param([string] $Message) - - # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. - return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + - 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + - 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + - '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + - '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + - 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') -} - -function Invoke-AzCommand { - param([string[]] $Arguments, [switch] $Interactive) - - $previousBroker = [Environment]::GetEnvironmentVariable('AZURE_CORE_ENABLE_BROKER_ON_WINDOWS', 'Process') - try { - if ($IsWindows -and -not $UseWindowsBroker) { - $env:AZURE_CORE_ENABLE_BROKER_ON_WINDOWS = 'false' - } - - # Handle native exit codes ourselves, including when the caller has enabled this preference. - $PSNativeCommandUseErrorActionPreference = $false - if ($Interactive) { - # Do not capture subscription-selector or sign-in prompts. Login uses --output none. - & az @Arguments | Out-Host - $exitCode = $LASTEXITCODE - $lines = @() - } - else { - $output = & az @Arguments 2>&1 - $exitCode = $LASTEXITCODE - $lines = @($output | ForEach-Object { "$_" } | - Where-Object { $_ -notmatch 'UserWarning|site-packages' }) - } - } - finally { - if ($IsWindows -and -not $UseWindowsBroker) { - [Environment]::SetEnvironmentVariable( - 'AZURE_CORE_ENABLE_BROKER_ON_WINDOWS', $previousBroker, 'Process') - } - } - - return [PSCustomObject]@{ ExitCode = $exitCode; Lines = $lines } -} - -function Assert-AzCommandSucceeded { - param($Result, [string[]] $Arguments) - - if ($Result.ExitCode -eq 0) { return } - - $operation = @() - foreach ($argument in $Arguments) { - if ($argument.StartsWith('-')) { break } - $operation += $argument - } - $message = $Result.Lines -join [Environment]::NewLine - $redact = $false - foreach ($argument in $Arguments) { - if ($argument.StartsWith('--')) { - $redact = $argument -in @('--value', '--settings', '--body', '--headers', - '--password', '--access-token', '--connection-string') - } - elseif ($redact -and $argument) { - $message = $message.Replace($argument, '') - } - } - - # Never include the complete command: some callers pass a private key or app settings. - throw "az $($operation -join ' ') failed (exit $($Result.ExitCode)):`n$message" -} - -function Get-AzureCliAccountResult { - $arguments = @('account', 'show', '--output', 'json', '--only-show-errors') - $selectedSubscription = if ($script:AzureCliContext) { $script:AzureCliContext.id } else { $SubscriptionId } - if ($selectedSubscription) { $arguments += @('--subscription', $selectedSubscription) } - return Invoke-AzCommand -Arguments $arguments -} - -function Connect-AzureCliSession { - param([string] $Resource) - - if ($NonInteractive) { - throw 'Azure CLI needs interactive sign-in. Run az login for the target tenant, or rerun without -NonInteractive. MFA and tenant policies cannot be refreshed silently.' - } - - $arguments = @('login', '--output', 'none', '--only-show-errors') - $targetTenant = if ($script:AzureCliContext) { $script:AzureCliContext.tenantId } else { $TenantId } - if ($targetTenant) { $arguments += @('--tenant', $targetTenant) } - if ($Resource) { $arguments += @('--scope', "$Resource/.default") } - Write-Host ' Azure sign-in: complete the sign-in/MFA prompt using the original provisioning account.' -ForegroundColor Yellow - $result = Invoke-AzCommand -Arguments $arguments -Interactive - Assert-AzCommandSucceeded -Result $result -Arguments $arguments - - if ($script:AzureCliContext) { - $result = Get-AzureCliAccountResult - Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'show') - $account = ($result.Lines -join "`n") | ConvertFrom-Json - if ($account.id -ne $script:AzureCliContext.id -or - $account.tenantId -ne $script:AzureCliContext.tenantId -or - $account.user.type -ne $script:AzureCliContext.user.type -or - $account.user.name -ne $script:AzureCliContext.user.name) { - throw 'Azure sign-in changed the subscription, tenant or account. Sign in with the original provisioning account before rerunning.' - } - $arguments = @('account', 'set', '--subscription', $script:AzureCliContext.id) - $result = Invoke-AzCommand -Arguments $arguments - Assert-AzCommandSucceeded -Result $result -Arguments $arguments - } -} - -function Get-AzureCliTokenResult { - param([ValidateSet('Arm', 'Graph', 'KeyVault')] [string] $ResourceName) - - # MSAL returns a usable cached token or refreshes it. Suppress the entire token response. - return Invoke-AzCommand -Arguments @('account', 'get-access-token', - '--subscription', $script:AzureCliContext.id, - '--resource', $script:AzureCliResources[$ResourceName], '--output', 'none', '--only-show-errors') -} - -function Ensure-AzureCliToken { - param([ValidateSet('Arm', 'Graph', 'KeyVault')] [string] $ResourceName) - - $result = Get-AzureCliTokenResult -ResourceName $ResourceName - if ($result.ExitCode -ne 0 -and (Test-AuthenticationFailure ($result.Lines -join "`n"))) { - Connect-AzureCliSession -Resource $script:AzureCliResources[$ResourceName] - $result = Get-AzureCliTokenResult -ResourceName $ResourceName - } - Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'get-access-token') -} - -function Assert-AzureCliTokens { - # No recursive recovery here: stop rather than alternating Graph/ARM sign-ins indefinitely. - foreach ($resourceName in @('Arm', 'Graph', 'KeyVault')) { - $result = Get-AzureCliTokenResult -ResourceName $resourceName - Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'get-access-token') - } -} - -function Initialize-AzureCliAuthentication { - $result = Get-AzureCliAccountResult - if ($result.ExitCode -ne 0 -and - ((Test-AuthenticationFailure ($result.Lines -join "`n")) -or - ($result.Lines -join "`n") -match '(?i)subscription .+doesn''t exist')) { - Connect-AzureCliSession - $result = Get-AzureCliAccountResult - } - Assert-AzCommandSucceeded -Result $result -Arguments @('account', 'show') - $account = ($result.Lines -join "`n") | ConvertFrom-Json - if ($account.state -ne 'Enabled') { throw "Subscription '$($account.name)' is $($account.state), not Enabled." } - if ($TenantId -and $account.tenantId -ne $TenantId) { - throw 'The selected subscription does not belong to -TenantId. Select the intended subscription before provisioning.' - } - if ($account.user.type -ne 'user') { - throw 'This script requires a user Azure CLI login to grant the signed-in user Key Vault access. Service-principal and managed-identity provisioning are not supported.' - } - $script:AzureCliContext = $account - $script:GraphTenantId = $account.tenantId - - $arguments = @('account', 'set', '--subscription', $account.id) - $result = Invoke-AzCommand -Arguments $arguments - Assert-AzCommandSucceeded -Result $result -Arguments $arguments - foreach ($resourceName in @('Arm', 'Graph', 'KeyVault')) { - Ensure-AzureCliToken -ResourceName $resourceName - } - Assert-AzureCliTokens - Write-Host ' Azure auth : ARM, Microsoft Graph and Key Vault ready' -} - -function Invoke-AzResult { - param([string[]] $Arguments) - - # Directory commands use the tenant selected at initialization/sign-in, not --subscription. - if ($script:AzureCliContext -and $Arguments[0] -ne 'ad' -and $Arguments -notcontains '--subscription') { - $Arguments += @('--subscription', $script:AzureCliContext.id) - } - if ($Arguments -notcontains '--only-show-errors') { $Arguments += '--only-show-errors' } - $result = Invoke-AzCommand -Arguments $Arguments - $message = $result.Lines -join "`n" - if ($result.ExitCode -ne 0 -and $script:AzureCliContext -and (Test-AuthenticationFailure $message)) { - $resourceName = if ($message -match 'https://graph\.microsoft\.com') { - 'Graph' - } - elseif ($message -match 'https://management\.(core\.windows\.net|azure\.com)') { - 'Arm' - } - elseif ($message -match 'https://vault\.azure\.net') { - 'KeyVault' - } - elseif ($Arguments[0] -eq 'ad') { 'Graph' } - elseif ($Arguments[0] -eq 'keyvault' -and $Arguments[1] -in @('secret', 'key', 'certificate')) { - 'KeyVault' - } - else { 'Arm' } - - Write-Host " Azure auth : refreshing $resourceName authentication; retrying the command once" -ForegroundColor Yellow - Ensure-AzureCliToken -ResourceName $resourceName - Assert-AzureCliTokens - $result = Invoke-AzCommand -Arguments $Arguments - } - return $result -} - -function Invoke-Az { - # Keep this a simple function: advanced-function parameters collide with CLI flags such as -o. - $result = Invoke-AzResult -Arguments $args - Assert-AzCommandSucceeded -Result $result -Arguments $args - return $result.Lines -} - -function Ensure-AzRoleAssignment { - param([string] $ObjectId, [string] $PrincipalType, [string] $Role, [string] $Scope) - - $roleId = Invoke-Az role definition list --name $Role --query '[0].id' --output tsv - if ([string]::IsNullOrWhiteSpace($roleId)) { throw "Could not resolve role '$Role'." } - $nextPage = "https://management.azure.com${Scope}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" - do { - # Read ARM directly, avoiding directory lookups just to display principal names. - $page = ((Invoke-Az rest --method get --url $nextPage --output json) -join "`n") | ConvertFrom-Json - $existing = @($page.value | Where-Object { - $_.properties.principalId -eq $ObjectId -and $_.properties.scope -eq $Scope -and - $_.properties.roleDefinitionId.Split('/')[-1] -eq $roleId.Split('/')[-1] - }) - if ($existing.Count) { - Write-Host " $Role already present" -ForegroundColor DarkGray - return - } - $nextPage = if ($page.PSObject.Properties['nextLink']) { $page.nextLink } else { $null } - } while ($nextPage) - - Confirm-SetupAction -Action 'create role assignment' -Target "$Role -> $ObjectId" ` - -Details "Principal type: $PrincipalType; exact scope: $Scope." - $arguments = @('role', 'assignment', 'create', '--assignee-object-id', $ObjectId, - '--assignee-principal-type', $PrincipalType, '--role', $Role, '--scope', $Scope, - '--output', 'none', '--only-show-errors') - $result = Invoke-AzResult -Arguments $arguments - if ($result.ExitCode -ne 0 -and ($result.Lines -join "`n") -match '\bRoleAssignmentExists\b') { - Write-Host " $Role already present" -ForegroundColor DarkGray - return - } - Assert-AzCommandSucceeded -Result $result -Arguments $arguments -} - -function Ensure-FunctionTelemetry { - param([string] $FunctionName, [string] $Group, [string] $Region, [string] $Tag) - - $components = ((Invoke-Az resource list --resource-group $Group ` - --resource-type Microsoft.Insights/components --output json) -join "`n") | ConvertFrom-Json - if (@($components | Where-Object name -eq $FunctionName).Count) { return $FunctionName } - - $stem = $FunctionName - if ($stem.Length -gt 58) { $stem = $stem.Substring(0, 58) } - $workspaceName = "$stem-logs" - $workspaces = ((Invoke-Az monitor log-analytics workspace list --resource-group $Group --output json) -join "`n") | - ConvertFrom-Json - if (-not @($workspaces | Where-Object name -eq $workspaceName).Count) { - Confirm-SetupAction -Action 'create Log Analytics workspace' -Target $workspaceName ` - -Details "Resource group: $Group; location: $Region; PerGB2018, 30-day retention. Ingestion charges apply." - Invoke-Az monitor log-analytics workspace create --workspace-name $workspaceName ` - --resource-group $Group --location $Region --sku PerGB2018 --retention-time 30 --tags $Tag | Out-Null - } - $workspaceId = Invoke-Az monitor log-analytics workspace show --workspace-name $workspaceName ` - --resource-group $Group --query id --output tsv - if ([string]::IsNullOrWhiteSpace($workspaceId)) { throw 'The telemetry workspace has no resource ID.' } - - $actionGroups = ((Invoke-Az resource list --resource-group $Group ` - --resource-type Microsoft.Insights/actionGroups --output json) -join "`n") | ConvertFrom-Json - if (-not @($actionGroups | Where-Object name -eq 'Application Insights Smart Detection').Count) { - Confirm-SetupAction -Action 'allow creation of the standard telemetry action group' ` - -Target 'Application Insights Smart Detection' ` - -Details "Azure may create this supporting resource alongside Application Insights in $Group." - } - Confirm-SetupAction -Action 'create Application Insights component' -Target $FunctionName ` - -Details "Resource group: $Group; location: $Region; workspace: $workspaceName. Local-key authentication is disabled." - $propertiesFile = Join-Path ([IO.Path]::GetTempPath()) "epp-insights-$([Guid]::NewGuid()).json" - [IO.File]::WriteAllText($propertiesFile, (@{ - Application_Type = 'web' - WorkspaceResourceId = $workspaceId - DisableLocalAuth = $true - } | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) - try { - Invoke-Az resource create --resource-group $Group --name $FunctionName ` - --resource-type Microsoft.Insights/components --api-version 2020-02-02 --location $Region ` - --properties "@$propertiesFile" | Out-Null - } - finally { Remove-Item -LiteralPath $propertiesFile -Force -ErrorAction SilentlyContinue } - return $FunctionName -} - -function New-OrRecoverEndpointKeyVault { - param([string] $Name, [string] $Group, [string] $Region, [string] $Tag) - - $deletedVaults = ((Invoke-Az keyvault list-deleted --output json) -join "`n") | ConvertFrom-Json - $matchingVaults = @($deletedVaults | Where-Object name -eq $Name) - if ($matchingVaults.Count -gt 1) { throw "More than one deleted vault matches '$Name'; resolve this before continuing." } - if ($matchingVaults.Count -eq 1) { - $deleted = $matchingVaults[0] - $expectedId = "/subscriptions/$($script:AzureCliContext.id)/resourceGroups/$Group/providers/Microsoft.KeyVault/vaults/$Name" - if ($deleted.properties.vaultId -ne $expectedId) { - throw "Deleted vault '$Name' belongs to another resource group. Choose a different -KeyVaultName; it will not be recovered or purged automatically." - } - Confirm-SetupAction -Action 'recover soft-deleted Key Vault' -Target $Name ` - -Details "Original location: $($deleted.properties.location); resource group: $Group. Recovery retains its existing keys and secrets. No purge will be performed." - Invoke-Az keyvault recover --name $Name --resource-group $Group ` - --location $deleted.properties.location | Out-Null - Write-Host " Key Vault : $Name recovered" - } - else { - $Region = Read-SetupValue -Name Location -DefaultValue $Region -Required - Confirm-SetupAction -Action 'create Key Vault' -Target $Name ` - -Details "Resource group: $Group; location: $Region; Standard SKU with Azure RBAC." - Invoke-Az keyvault create --name $Name --resource-group $Group --location $Region ` - --enable-rbac-authorization true --sku standard --tags $Tag | Out-Null - Write-Host " Key Vault : $Name created" - } -} - -function Connect-EndpointGraph { - param([switch] $Reconnect, [string[]] $Scopes) - - if ($PSBoundParameters.ContainsKey('Scopes')) { - if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { - throw 'Graph authentication requires at least one nonempty scope.' - } - $script:GraphRequiredScopes = $Scopes - } - - $context = Get-MgContext -ErrorAction Stop - $canReuse = $context -and $context.AuthType -eq 'Delegated' -and - $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and - $context.Environment -eq 'Global' -and - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and - (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) - - if ($Reconnect -or -not $canReuse) { - if ($NonInteractive) { - throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." - } - $connectParameters = @{ - Scopes = $script:GraphRequiredScopes - ContextScope = 'Process' - Environment = 'Global' - NoWelcome = $true - ErrorAction = 'Stop' - } - if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } - Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow - Connect-MgGraph @connectParameters | Out-Null - $context = Get-MgContext -ErrorAction Stop - } - - if (-not $context -or $context.AuthType -ne 'Delegated' -or - $context.Environment -ne 'Global' -or - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or - ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or - ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { - throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' - } - $script:GraphTenantId = $context.TenantId - $script:GraphAccountName = $context.Account -} - -function Invoke-EndpointGraph { - param([scriptblock] $Operation) - - try { - & $Operation - } - catch { - $exception = $_.Exception - $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) - while ($exception) { - if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or - ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or - ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { - $authenticationFailure = $true - } - $exception = $exception.InnerException - } - if (-not $authenticationFailure) { throw } - Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow - Connect-EndpointGraph -Reconnect - & $Operation - } -} - -function Get-CyotApplication { - param([string] $ApplicationId, [switch] $RequireMultiTenant) - - $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid - $matches = @(Invoke-EndpointGraph { - Get-MgApplication -Filter "appId eq '$ApplicationId'" -Property Id, AppId -All -ErrorAction Stop - }) - if ($matches.Count -ne 1) { - throw "Expected exactly one app registration with client ID '$ApplicationId' in tenant '$script:GraphTenantId'; found $($matches.Count). Complete app registration in the correct tenant. No replacement app will be created." - } - $application = Invoke-EndpointGraph { - Get-MgApplication -ApplicationId $matches[0].Id ` - -Property Id, AppId, DisplayName, SignInAudience, Api, IdentifierUris, KeyCredentials, TokenEncryptionKeyId ` - -ErrorAction Stop - } - if (-not $application -or $application.AppId -ne $ApplicationId) { - throw 'Graph did not return the requested application.' - } - if ($RequireMultiTenant -and $application.SignInAudience -ne 'AzureADMultipleOrgs') { - throw "Application '$ApplicationId' is not multi-tenant. Review and approve that change in the app-registration stage before provisioning." - } - return $application -} - -function Ensure-CyotEndpointServicePrincipal { - param([string] $ApplicationId) - - $principals = @(Invoke-EndpointGraph { - Get-MgServicePrincipal -Filter "appId eq '$ApplicationId'" -All -ErrorAction Stop - }) - if ($principals.Count -gt 1) { throw "Multiple service principals match application '$ApplicationId'." } - if (-not $principals.Count) { - Confirm-SetupAction -Action 'create endpoint service principal' -Target $ApplicationId ` - -Details "Tenant: $script:GraphTenantId. No permission is granted to the provider or Microsoft." - $principal = Invoke-EndpointGraph { New-MgServicePrincipal -AppId $ApplicationId -ErrorAction Stop } - } - else { $principal = $principals[0] } - if (-not $principal -or -not $principal.Id) { throw 'Graph did not return an endpoint service-principal ID.' } - if ($principal.AppRoleAssignmentRequired) { - Confirm-SetupAction -Action 'remove endpoint app-role assignment requirement' -Target $principal.Id ` - -Details 'Microsoft EPP uses its pre-authorized caller identity. Easy Auth must still restrict callers to the Microsoft EPP application.' - Invoke-EndpointGraph { - Update-MgServicePrincipal -ServicePrincipalId $principal.Id -AppRoleAssignmentRequired:$false -ErrorAction Stop - } | Out-Null - } - return $principal -} - -function Get-ProviderEntraSettings { - param([string] $ProviderTenantId, [string] $ProviderScope) - - $ProviderTenantId = Read-SetupValue -Name ProviderTenantId -DefaultValue $ProviderTenantId -Required -ValueType Guid - $ProviderScope = Read-SetupValue -Name ProviderScope -DefaultValue $ProviderScope -Required -ValueType Scope - return @{ - EPP_PROVIDER_AUTH_MODE = 'ests' - EPP_PROVIDER_TENANT_ID = $ProviderTenantId - EPP_PROVIDER_SCOPE = $ProviderScope - } -} - -function Ensure-CyotProviderIdentity { - param( - [string] $FunctionName, [string] $Group, [string] $Region, [string] $Tag, - [string] $IdentityName, $Application - ) - - if ($script:AzureCliContext.tenantId -ne $script:GraphTenantId -or - $Application.SignInAudience -ne 'AzureADMultipleOrgs') { - throw 'Provider federation requires a multi-tenant application and managed identity in the same customer tenant.' - } - if ([string]::IsNullOrWhiteSpace($IdentityName)) { $IdentityName = "$FunctionName-outbound" } - if ($IdentityName -notmatch '^[a-zA-Z0-9_-]{3,128}$') { - throw '-OutboundIdentityName must be 3-128 letters, digits, underscores or hyphens.' - } - - $identities = ((Invoke-Az identity list --resource-group $Group --output json) -join "`n") | ConvertFrom-Json - $matches = @($identities | Where-Object name -eq $IdentityName) - if ($matches.Count -gt 1) { throw "Multiple managed identities match '$IdentityName'." } - if (-not $matches.Count) { - $Region = Read-SetupValue -Name Location -DefaultValue $Region -Required - Confirm-SetupAction -Action 'create outbound user-assigned managed identity' -Target $IdentityName ` - -Details "Resource group: $Group; location: $Region. This identity will authenticate as application $($Application.AppId) to the provider." - Invoke-Az identity create --name $IdentityName --resource-group $Group --location $Region --tags $Tag | Out-Null - } - $identity = ((Invoke-Az identity show --name $IdentityName --resource-group $Group --output json) -join "`n") | - ConvertFrom-Json - if (-not $identity.id -or -not $identity.clientId -or -not $identity.principalId -or - $identity.tenantId -ne $script:GraphTenantId) { - throw 'The outbound managed identity is incomplete or belongs to another tenant.' - } - - $functionIdentity = ((Invoke-Az functionapp identity show --name $FunctionName --resource-group $Group --output json) -join "`n") | - ConvertFrom-Json - $userIdentities = @() - if ($functionIdentity.PSObject.Properties['userAssignedIdentities'] -and $functionIdentity.userAssignedIdentities) { - $userIdentities = @($functionIdentity.userAssignedIdentities.PSObject.Properties.Name) - } - if ($userIdentities -notcontains $identity.id) { - Confirm-SetupAction -Action 'attach outbound managed identity to Function App' -Target $FunctionName ` - -Details "Attach $($identity.id). Retain the system-assigned identity and all existing user-assigned identities." - $identityIds = @('[system]') + $userIdentities + @($identity.id) - Invoke-Az functionapp identity assign --name $FunctionName --resource-group $Group --identities @identityIds | Out-Null - } - - $issuer = "https://login.microsoftonline.com/$script:GraphTenantId/v2.0" - $audience = 'api://AzureADTokenExchange' - $credentialName = "cyot-$FunctionName-outbound" - $credentials = @(Invoke-EndpointGraph { - Get-MgApplicationFederatedIdentityCredential -ApplicationId $Application.Id -All -ErrorAction Stop - }) - $matchingCredentials = @($credentials | Where-Object { - $_.Issuer -ceq $issuer -and $_.Subject -ceq $identity.principalId -and - @($_.Audiences).Count -eq 1 -and $_.Audiences[0] -ceq $audience - }) - if (-not $matchingCredentials.Count) { - if (@($credentials | Where-Object Name -eq $credentialName).Count) { - throw "Federated credential '$credentialName' already exists with a different trust relationship. It will not be overwritten." - } - Confirm-SetupAction -Action 'create application federated identity credential' -Target "$($Application.AppId)/$credentialName" ` - -Details "Trust managed-identity principal $($identity.principalId), issuer $issuer, audience $audience. No client secret is created." - Invoke-EndpointGraph { - New-MgApplicationFederatedIdentityCredential -ApplicationId $Application.Id -BodyParameter @{ - Name = $credentialName - Issuer = $issuer - Subject = $identity.principalId - Audiences = @($audience) - } -ErrorAction Stop - } | Out-Null - } - return @{ - EPP_OUTBOUND_CLIENT_ID = $Application.AppId - EPP_OUTBOUND_MI_CLIENT_ID = $identity.clientId - } -} - -$stageResult = $null -$stageSucceeded = $false - -try { -Initialize-SetupLogging -$guidedEndpointSelection = [string]::IsNullOrWhiteSpace($FunctionAppName) -and [string]::IsNullOrWhiteSpace($EndpointUrl) -$endpointSelection = Resolve-EndpointParameters -FunctionName $FunctionAppName -ExistingEndpoint $EndpointUrl -if ($endpointSelection.ExitRequested) { - Write-Host 'Stage 2 exited without making changes.' -ForegroundColor Yellow - $stageSucceeded = $true - return -} -$FunctionAppName = $endpointSelection.FunctionAppName -$EndpointUrl = $endpointSelection.EndpointUrl -$provisionFunction = $endpointSelection.ProvisionFunction -if ($guidedEndpointSelection -and -not $NonInteractive -and -not $PSBoundParameters.ContainsKey('StartFromStep')) { - $StartFromStep = Show-ResumeSelectionMenu -} -Write-SetupEvent -Level INFO -Message "Starting Stage 2 from step $StartFromStep. Earlier prerequisites will be verified and reconstructed." - -# --------------------------------------------------------------------------- -# 1. Provision the Azure Function -# --------------------------------------------------------------------------- -# The app already exists from stage 1; only its hostname-based identifier URI must wait for the host. -if ($provisionFunction -and $StartFromStep -le 1) { - Write-Step 'Provisioning the Azure Function' - - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - throw 'Azure CLI is required to provision the Function. Install it, or pass -EndpointUrl to skip provisioning.' - } - - Initialize-AzureCliAuthentication - $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid - Connect-EndpointGraph -Scopes @('Application.ReadWrite.All') - $application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant - $subscriptionName = $script:AzureCliContext.name - $resolvedSubscriptionId = $script:AzureCliContext.id - Write-Host " Subscription : $subscriptionName" - - $ResourceGroup = Read-SetupValue -Name ResourceGroup -DefaultValue $ResourceGroup -Required - $ResourceTagName = Read-SetupValue -Name ResourceTagName -DefaultValue $ResourceTagName -Required - $ResourceTagValue = Read-SetupValue -Name ResourceTagValue -DefaultValue $ResourceTagValue -Required - $resourceTag = "$ResourceTagName=$ResourceTagValue" - - $groupExists = Invoke-Az group exists --name $ResourceGroup --output tsv - if ($groupExists -eq 'false') { - $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required - Confirm-SetupAction -Action 'create resource group' -Target $ResourceGroup -Details "Location: $Location." - Invoke-Az group create --name $ResourceGroup --location $Location --tags $resourceTag | Out-Null - } - elseif ($groupExists -ne 'true') { throw "Unexpected resource-group existence response: $groupExists" } - Write-Host " Resource group: $ResourceGroup" - - # Derive optional resource names without prompting; only validate them when needed. - if ([string]::IsNullOrWhiteSpace($StorageAccountName)) { - $StorageAccountName = Get-DefaultStorageAccountName $FunctionAppName - } - $StorageAccountName = Read-SetupValue -Name StorageAccountName -DefaultValue $StorageAccountName -Required -ValueType StorageName - $storageExists = (Invoke-Az storage account list --resource-group $ResourceGroup --query "[?name=='$StorageAccountName'] | length(@)" -o tsv) - if ($storageExists -eq '0') { - $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required - Confirm-SetupAction -Action 'create storage account' -Target $StorageAccountName ` - -Details "Resource group: $ResourceGroup; location: $Location; SKU: Standard_LRS. Storage charges apply." - Invoke-Az storage account create ` - --name $StorageAccountName ` - --resource-group $ResourceGroup ` - --location $Location ` - --sku Standard_LRS ` - --min-tls-version TLS1_2 ` - --allow-blob-public-access false ` - --tags $resourceTag | Out-Null - Write-Host " Storage : $StorageAccountName created" - } - else { - Write-Host " Storage : $StorageAccountName exists" - } - - $functionExists = (Invoke-Az functionapp list --resource-group $ResourceGroup --query "[?name=='$FunctionAppName'] | length(@)" -o tsv) - - if ($functionExists -eq '0') { - $Location = Read-SetupValue -Name Location -DefaultValue $Location -Required - $PlanType = Read-SetupValue -Name PlanType -DefaultValue $PlanType -Required ` - -ValueType Choice -Choices @('FlexConsumption', 'Premium') - # Create telemetry explicitly so no workspace appears silently in a different resource group. - $insightsName = Ensure-FunctionTelemetry -FunctionName $FunctionAppName ` - -Group $ResourceGroup -Region $Location -Tag $resourceTag - if ($PlanType -eq 'FlexConsumption') { - # Flex Consumption supports always-ready instances, which is the only way a consumption - # style plan stays inside the 3.2 s budget. Requires Azure CLI 2.61 or later. - # Node 24 to match the reference package. Node 20 is out of support and Node 22, though - # still the platform default, reaches end of life in April 2027. - Confirm-SetupAction -Action 'create Flex Consumption hosting plan' -Target "$FunctionAppName (CLI-assigned plan name)" ` - -Details "Resource group: $ResourceGroup; location: $Location. The CLI creates this together with the Function. One always-ready instance incurs charges." - Confirm-SetupAction -Action 'create deployment storage container' -Target "$StorageAccountName (CLI-managed container)" ` - -Details 'The Function creation command creates or reuses its deployment container in this storage account.' - Confirm-SetupAction -Action 'create Function App' -Target $FunctionAppName ` - -Details "Resource group: $ResourceGroup; location: $Location; Node 24, Flex Consumption." - Invoke-Az functionapp create ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --storage-account $StorageAccountName ` - --app-insights $insightsName ` - --flexconsumption-location $Location ` - --runtime node ` - --runtime-version 24 ` - --instance-memory 2048 | Out-Null - - # Without this the first request after an idle period pays a cold start and times out. - Invoke-Az functionapp scale config always-ready set ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --settings http=1 | Out-Null - - Write-Host " Plan : Flex Consumption, 1 always-ready instance" - } - else { - $planName = "$FunctionAppName-plan" - $plans = ((Invoke-Az functionapp plan list --resource-group $ResourceGroup --output json) -join "`n") | ConvertFrom-Json - if (-not @($plans | Where-Object name -eq $planName).Count) { - Confirm-SetupAction -Action 'create Premium hosting plan' -Target $planName ` - -Details "Resource group: $ResourceGroup; location: $Location; Linux EP1. Ongoing charges apply." - Invoke-Az functionapp plan create ` - --name $planName ` - --resource-group $ResourceGroup ` - --location $Location ` - --sku EP1 ` - --is-linux true | Out-Null - } - Confirm-SetupAction -Action 'create Function runtime storage' -Target "$StorageAccountName (Function-managed content storage)" ` - -Details 'Azure creates or reuses its content share and runtime containers during Function creation.' - Confirm-SetupAction -Action 'create Function App' -Target $FunctionAppName ` - -Details "Resource group: $ResourceGroup; Linux Node 24, Premium plan: $planName; storage: $StorageAccountName." - Invoke-Az functionapp create ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --storage-account $StorageAccountName ` - --app-insights $insightsName ` - --plan $planName ` - --runtime node ` - --runtime-version 24 ` - --functions-version 4 | Out-Null - - Write-Host " Plan : Premium EP1, always warm" - } - - Write-Host " Function app : $FunctionAppName created" - } - else { - Write-Host " Function app : $FunctionAppName exists" - } - - # Microsoft rejects any endpoint that is not HTTPS, so leaving the HTTP listener open only - # invites a delivery that never happens. - Invoke-Az functionapp update ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --set httpsOnly=true | Out-Null - - Invoke-Az functionapp config set ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --min-tls-version 1.2 | Out-Null - - # A managed identity is how the Function reaches Key Vault, and how the platform reaches storage - # once the account keys below are removed. - $principalId = Invoke-Az functionapp identity show --name $FunctionAppName ` - --resource-group $ResourceGroup --query principalId --output tsv - if ([string]::IsNullOrWhiteSpace($principalId)) { - Confirm-SetupAction -Action 'create system-assigned managed identity' -Target $FunctionAppName ` - -Details "This creates the Function's service principal in tenant $($script:AzureCliContext.tenantId)." - $principalId = (Invoke-Az functionapp identity assign ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --query principalId -o tsv) - } - - # --- Storage: managed identity instead of account keys -------------------------------------- - # A new function app is created with the storage account key embedded in AzureWebJobsStorage - # and, on Flex Consumption, again in a second setting for the deployment container. Both are - # replaced here so no account key is left in configuration for anyone to read or leak. - $storageId = (Invoke-Az storage account show ` - --name $StorageAccountName ` - --resource-group $ResourceGroup ` - --query id -o tsv) - - foreach ($role in @( - 'Storage Blob Data Contributor', - 'Storage Queue Data Contributor', - 'Storage Table Data Contributor')) { - - Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` - -Role $role -Scope $storageId - } - - Write-Host ' Storage roles : blob, queue and table data contributor' - - # Role assignments take time to reach the data plane. Switching over immediately produces an - # authorization failure on the next deployment that reads like a corrupt package. - Start-Sleep -Seconds 30 - - Invoke-Az functionapp deployment config set ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --deployment-storage-auth-type SystemAssignedIdentity | Out-Null - - Invoke-Az functionapp config appsettings set ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --settings "AzureWebJobsStorage__accountName=$StorageAccountName" | Out-Null - - # Removed last. Deleting the connection strings before the identity path is in place would - # strand the host with no way to reach its own storage. - Invoke-Az functionapp config appsettings delete ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --setting-names AzureWebJobsStorage DEPLOYMENT_STORAGE_CONNECTION_STRING ` - -o none --only-show-errors | Out-Null - - Write-Host ' Storage auth : managed identity, no account key in configuration' - - # --- Key Vault for the encryption private key ------------------------------------------------ - # The private key is the one secret that matters: it is the only thing that can open a passcode. - # It goes in a vault and reaches the Function as a reference, so it never appears in app settings - # where anyone with Reader on the site could read it. - if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { - # 3-24 characters, alphanumerics and hyphens, globally unique. - $KeyVaultName = Get-DefaultKeyVaultName $FunctionAppName - } - $KeyVaultName = Read-SetupValue -Name KeyVaultName -DefaultValue $KeyVaultName -Required -ValueType VaultName - - $vaultExists = (Invoke-Az keyvault list --resource-group $ResourceGroup ` - --query "[?name=='$KeyVaultName'] | length(@)" -o tsv) - - if ($vaultExists -eq '0') { - New-OrRecoverEndpointKeyVault -Name $KeyVaultName -Group $ResourceGroup -Region $Location -Tag $resourceTag - } - else { - Write-Host " Key Vault : $KeyVaultName exists" - } - - $vaultId = (Invoke-Az keyvault show --name $KeyVaultName --resource-group $ResourceGroup --query id -o tsv) - - # The Function reads the secret; whoever runs this script writes it. - Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` - -Role 'Key Vault Secrets User' -Scope $vaultId - - $callerObjectId = (Invoke-Az ad signed-in-user show --query id -o tsv) - Ensure-AzRoleAssignment -ObjectId $callerObjectId -PrincipalType User ` - -Role 'Key Vault Secrets Officer' -Scope $vaultId - - Write-Host ' Key Vault RBAC: function reads secrets, you write them' - - # Read from ARM rather than 'az functionapp show'. On Flex Consumption that command returns null - # for defaultHostName, state and hostNames while still exiting 0, so the hostname silently comes - # back empty and the failure only shows up later as an unparseable URI. - $defaultHostName = (Invoke-Az resource show ` - --resource-group $ResourceGroup ` - --name $FunctionAppName ` - --resource-type Microsoft.Web/sites ` - --query properties.defaultHostName -o tsv) - - if ([string]::IsNullOrWhiteSpace($defaultHostName)) { - throw "Could not read the hostname for '$FunctionAppName'. The app may still be provisioning." - } - - $FunctionRoute = Read-SetupValue -Name FunctionRoute -DefaultValue $FunctionRoute -Required - $EndpointUrl = "https://$defaultHostName/$($FunctionRoute.TrimStart('/'))" - - Write-Host " Identity : $principalId" - Write-Host " Endpoint URL : $EndpointUrl" -} -elseif ($provisionFunction) { - Write-Step "Reconstructing Azure state before resumed step $StartFromStep" - - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - throw 'Azure CLI is required to resume Function configuration.' - } - Initialize-AzureCliAuthentication - $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid - $resolvedSubscriptionId = $script:AzureCliContext.id - $ResourceGroup = Read-SetupValue -Name ResourceGroup -DefaultValue $ResourceGroup -Required - $ResourceTagName = Read-SetupValue -Name ResourceTagName -DefaultValue $ResourceTagName -Required - $ResourceTagValue = Read-SetupValue -Name ResourceTagValue -DefaultValue $ResourceTagValue -Required - $resourceTag = "$ResourceTagName=$ResourceTagValue" - - $groupExists = Invoke-Az group exists --name $ResourceGroup --output tsv - if ($groupExists -ne 'true') { - throw "Resource group '$ResourceGroup' is missing. Resume with -StartFromStep 1." - } - $functionExists = Invoke-Az functionapp list --resource-group $ResourceGroup --query "[?name=='$FunctionAppName'] | length(@)" -o tsv - if ($functionExists -eq '0') { - throw "Function app '$FunctionAppName' is missing. Resume with -StartFromStep 1." - } - - $functionResource = ((Invoke-Az resource show --resource-group $ResourceGroup --name $FunctionAppName ` - --resource-type Microsoft.Web/sites --output json) -join "`n") | ConvertFrom-Json - $defaultHostName = $functionResource.properties.defaultHostName - $Location = $functionResource.location - if ([string]::IsNullOrWhiteSpace($defaultHostName)) { - throw "Could not reconstruct the hostname for '$FunctionAppName'. Resume with -StartFromStep 1." - } - - $principalId = Invoke-Az functionapp identity show --name $FunctionAppName ` - --resource-group $ResourceGroup --query principalId --output tsv - if ([string]::IsNullOrWhiteSpace($principalId)) { - throw "Function app '$FunctionAppName' has no system-assigned identity. Resume with -StartFromStep 1." - } - - if ([string]::IsNullOrWhiteSpace($KeyVaultName)) { $KeyVaultName = Get-DefaultKeyVaultName $FunctionAppName } - $KeyVaultName = Read-SetupValue -Name KeyVaultName -DefaultValue $KeyVaultName -Required -ValueType VaultName - $vaultExists = Invoke-Az keyvault list --resource-group $ResourceGroup ` - --query "[?name=='$KeyVaultName'] | length(@)" -o tsv - if ($vaultExists -eq '0') { - throw "Key Vault '$KeyVaultName' is missing. Resume with -StartFromStep 1." - } - - $FunctionRoute = Read-SetupValue -Name FunctionRoute -DefaultValue $FunctionRoute -Required - $EndpointUrl = "https://$defaultHostName/$($FunctionRoute.TrimStart('/'))" - Write-Host " Function app : $FunctionAppName" - Write-Host " Endpoint URL : $EndpointUrl" - Write-Host " Key Vault : $KeyVaultName" -} - -# --------------------------------------------------------------------------- -# 2. Validate the endpoint URL against the rules Microsoft enforces per delivery -# --------------------------------------------------------------------------- -Write-Step 'Validating the endpoint URL' - -$uri = [System.Uri]::new($EndpointUrl) - -if ($uri.Scheme -ne 'https') { - throw "The endpoint must use HTTPS. Got '$($uri.Scheme)'." -} -if ($uri.IsLoopback) { - throw 'The endpoint must not be a loopback address. Microsoft rejects these before sending.' -} -if ($uri.HostNameType -in @('IPv4', 'IPv6')) { - throw 'The endpoint must use a hostname, not a literal IP address.' -} - -$endpointHost = $uri.Host -Write-Host " Endpoint host : $endpointHost" - -# --------------------------------------------------------------------------- -# 3. Read the existing stage-1 application -# --------------------------------------------------------------------------- -Write-Step 'Connecting to Microsoft Graph' - -# The SDK owns its own refreshable credentials; an Azure CLI access token is not interchangeable. -Connect-EndpointGraph -$ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid -$application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant -$appId = $application.AppId -$graphContext = Get-MgContext -ErrorAction Stop -$tenantId = $script:GraphTenantId -Write-Host " Graph account : $($graphContext.Account)" -Write-Host " Tenant : $tenantId" - -# --------------------------------------------------------------------------- -# 4. Encryption certificate -# --------------------------------------------------------------------------- -Write-Step 'Preparing the encryption certificate' - -$CertificatePath = Read-SetupValue -Name CertificatePath -DefaultValue $CertificatePath -ValueType File -if ($CertificatePath) { - $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath) - Write-Host " Using : $CertificatePath" -} -else { - # Reuse a certificate this script created earlier for the same host, if one is still valid. - # Minting a fresh certificate on every run leaves a trail of key credentials on the application - # and orphaned private keys in the store, which makes "safe to re-run" untrue in the one place - # it matters most. - $subject = "CN=$endpointHost External Phone Provider Encryption" - $certificate = Get-ChildItem Cert:\CurrentUser\My | - Where-Object { $_.Subject -eq $subject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date).AddDays(30) } | - Sort-Object NotAfter -Descending | - Select-Object -First 1 - - if ($certificate) { - Write-Host " Reusing : $($certificate.Thumbprint) (expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))" - } - elseif ($StartFromStep -gt 4) { - throw "No reusable encryption certificate was found for '$endpointHost'. Resume with -StartFromStep 4 or supply -CertificatePath." - } - else { - # RSA 2048 is the minimum Microsoft accepts. Keep the private key safe: it is the only thing - # that can open a passcode, and Microsoft never has a copy. - Confirm-SetupAction -Action 'create encryption certificate' -Target $subject ` - -Details "RSA 2048, one-year validity, CurrentUser\My. The public certificate will be exported alongside this script." - $certificate = New-SelfSignedCertificate ` - -Subject $subject ` - -CertStoreLocation 'Cert:\CurrentUser\My' ` - -KeyAlgorithm RSA ` - -KeyLength 2048 ` - -KeyExportPolicy Exportable ` - -KeyUsage KeyEncipherment, DataEncipherment ` - -NotAfter (Get-Date).AddYears(1) - - $exportPath = Join-Path $PSScriptRoot "phone-provider-encryption-$endpointHost.cer" - Export-Certificate -Cert $certificate -FilePath $exportPath -Force | Out-Null - - Write-Host " Created : $($certificate.Thumbprint)" - Write-Host " Public copy : $exportPath" - } -} - -if ($certificate.PublicKey.Key.KeySize -lt 2048) { - throw "The encryption key must be at least 2048 bits. Got $($certificate.PublicKey.Key.KeySize)." -} - -# --------------------------------------------------------------------------- -# 5. Reuse the stage-1 registration -# --------------------------------------------------------------------------- -Write-Step 'Configuring the application from stage 1' -Write-Host " Reusing : $appId ($($application.DisplayName))" - -# --------------------------------------------------------------------------- -# 6. Identifier URI - binds the application to the endpoint host -# --------------------------------------------------------------------------- -Write-Step 'Publishing the identifier URI' - -# Host only. No port, no path. Microsoft builds this same string and asks Entra for a token against -# it, so a mismatch means no token is ever issued and nothing is delivered. -$identifierUri = "api://$endpointHost/$appId" - -$existingUris = @($application.IdentifierUris) -if ($existingUris -notcontains $identifierUri) { - if ($StartFromStep -gt 6) { - throw "Identifier URI '$identifierUri' is missing. Resume with -StartFromStep 6." - } - Invoke-EndpointGraph { - Update-MgApplication -ApplicationId $application.Id -IdentifierUris (@($existingUris) + $identifierUri) -ErrorAction Stop - } -} - -Write-Host " Identifier URI: $identifierUri" - -# --------------------------------------------------------------------------- -# 7. Key credential with usage Encrypt -# --------------------------------------------------------------------------- -Write-Step 'Publishing the encryption key' - -# usage must be 'Encrypt'. A signing credential is not interchangeable, and Microsoft filters on this. -# -# An existing credential for this same certificate is reused. Publishing a second credential for a -# certificate the application already carries leaves stale keys accumulating on the registration, -# and every one of them is a key someone could later be confused by. -$certHash = $certificate.GetCertHash() -$existingCredential = @($application.KeyCredentials) | - Where-Object { $_.CustomKeyIdentifier -and (-not (Compare-Object $_.CustomKeyIdentifier $certHash)) } | - Select-Object -First 1 - -if ($existingCredential) { - $keyId = $existingCredential.KeyId - Write-Host " Key id : $keyId (already published)" -} -else { - if ($StartFromStep -gt 7) { - throw "The encryption certificate is not published on the application. Resume with -StartFromStep 7." - } - $keyId = [Guid]::NewGuid().ToString() - Confirm-SetupAction -Action 'publish new encryption key credential' -Target "$appId / $keyId" ` - -Details "Tenant: $tenantId; certificate: $($certificate.Thumbprint). Only the public key is published." - - $keyCredential = @{ - CustomKeyIdentifier = $certHash - DisplayName = "external phone provider encryption $($certificate.Thumbprint)" - Key = $certificate.GetRawCertData() - KeyId = $keyId - Type = 'AsymmetricX509Cert' - Usage = 'Encrypt' - StartDateTime = $certificate.NotBefore.ToUniversalTime() - EndDateTime = $certificate.NotAfter.ToUniversalTime() - } - - $currentKeys = @($application.KeyCredentials | Where-Object { $_.KeyId -ne $keyId }) - - Invoke-EndpointGraph { - Update-MgApplication -ApplicationId $application.Id ` - -KeyCredentials (@($currentKeys) + $keyCredential) ` - -TokenEncryptionKeyId $keyId -ErrorAction Stop - } - - Write-Host " Key id : $keyId" -} - -# Nominated every time: on a reused credential this is a no-op, and on a rotation it is the step -# that actually points Microsoft at the new key. -if ($StartFromStep -gt 7 -and $application.TokenEncryptionKeyId -ne $keyId) { - throw "The published key is not nominated as tokenEncryptionKeyId. Resume with -StartFromStep 7." -} -if ($StartFromStep -le 7) { - Invoke-EndpointGraph { - Update-MgApplication -ApplicationId $application.Id -TokenEncryptionKeyId $keyId -ErrorAction Stop - } -} -Write-Host ' Nominated as tokenEncryptionKeyId' - -# --------------------------------------------------------------------------- -# 8. Service principals -# --------------------------------------------------------------------------- -Write-Step 'Creating service principals' - -$endpointSp = if ($StartFromStep -le 8) { - Ensure-CyotEndpointServicePrincipal -ApplicationId $appId -} -else { - Invoke-EndpointGraph { Get-MgServicePrincipal -Filter "appId eq '$appId'" -ErrorAction Stop } | Select-Object -First 1 -} -if (-not $endpointSp) { throw "The endpoint service principal is missing. Resume with -StartFromStep 8." } -Write-Host " Endpoint SP : $($endpointSp.Id)" - -# Microsoft's application is normally provisioned on first use. Creating it now turns a first-call -# failure into a setup-time one, which is easier to diagnose. Nothing is granted to it. -$microsoftSp = Invoke-EndpointGraph { - Get-MgServicePrincipal -Filter "appId eq '$MicrosoftPhoneProviderAppId'" -ErrorAction Stop -} | - Select-Object -First 1 - -if (-not $microsoftSp) { - if ($StartFromStep -gt 8) { - throw "The Microsoft phone-provider service principal is missing. Resume with -StartFromStep 8." - } - Confirm-SetupAction -Action 'create Microsoft service principal in this tenant' -Target $MicrosoftPhoneProviderAppId ` - -Details "Tenant: $tenantId. No application permissions are granted by this action." - $microsoftSp = Invoke-EndpointGraph { - New-MgServicePrincipal -AppId $MicrosoftPhoneProviderAppId -ErrorAction Stop - } - Write-Host ' Microsoft SP : created' -} -else { - Write-Host ' Microsoft SP : exists' -} - -# --------------------------------------------------------------------------- -# 9. Secure the Function, tell it what to accept, and deploy the code -# --------------------------------------------------------------------------- -# Deferred to here because none of it can be known until the application exists: the audience is -# built from the hostname and the application id, so the Function has to be created bare in step 1 -# and secured on a second pass once the registration is in place. -if ($provisionFunction -and $StartFromStep -le 9) { - Write-Step 'Storing the private key in Key Vault' - - # PKCS#8 is the form crypto.createPrivateKey and most libraries read without coaxing. Base64 on - # top of it so the PEM's newlines survive being carried as a secret value and then as an - # environment variable. - $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate) - if (-not $rsa) { throw 'The certificate carries no RSA private key.' } - - $pemBuilder = [System.Text.StringBuilder]::new() - [void]$pemBuilder.AppendLine('-----BEGIN PRIVATE KEY-----') - [void]$pemBuilder.AppendLine([Convert]::ToBase64String($rsa.ExportPkcs8PrivateKey(), [Base64FormattingOptions]::InsertLineBreaks)) - [void]$pemBuilder.AppendLine('-----END PRIVATE KEY-----') - - $secretValue = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pemBuilder.ToString())) - $secretName = 'phone-provider-decryption-key' - Confirm-SetupAction -Action 'create a Key Vault secret version' -Target "$KeyVaultName/$secretName" ` - -Details 'Stores the encryption private key. An existing secret will receive a new version; its value is never displayed.' - - # Role assignments on a new vault take time to reach the data plane, and the first write is what - # discovers that. Retried rather than failed, because the alternative is a script that works only - # on the second run. - $secretId = $null - $secretArguments = @('keyvault', 'secret', 'set', - '--vault-name', $KeyVaultName, '--name', $secretName, '--value', $secretValue, - '--query', 'id', '--output', 'tsv', '--only-show-errors') - foreach ($attempt in 1..6) { - $secretResult = Invoke-AzResult -Arguments $secretArguments - if ($secretResult.ExitCode -eq 0) { - $secretId = ($secretResult.Lines -join '').Trim() - if (-not $secretId) { throw 'Key Vault returned success without a secret ID.' } - break - } - - if ($attempt -eq 6 -or ($secretResult.Lines -join "`n") -notmatch - 'ForbiddenByRbac|Caller is not authorized to perform action on resource') { - Assert-AzCommandSucceeded -Result $secretResult -Arguments $secretArguments - } - Write-Host " Key Vault RBAC: waiting for the secret-write permission (attempt $attempt/6)" -ForegroundColor DarkGray - Start-Sleep -Seconds 15 - } - - # Versionless, so rotating the key does not require touching the app setting. - $secretUri = ($secretId -replace '/[^/]+$', '') - Write-Host " Secret : $secretName" - Write-Host " Reference : $secretUri" - - Write-Step 'Configuring and deploying the Function' - - # A reused application may have been created in the portal, which pins v2. Read what is actually - # there rather than assuming, because the token version decides both aud and iss, and a validator - # configured for the wrong one rejects every delivery. - $tokenVersion = 1 - if ($application.PSObject.Properties.Name -contains 'Api' -and $application.Api -and - $application.Api.RequestedAccessTokenVersion) { - $tokenVersion = [int]$application.Api.RequestedAccessTokenVersion - } - - if ($tokenVersion -eq 2) { - $issuer = "https://login.microsoftonline.com/$tenantId/v2.0" - $expectedAudience = $appId - } - else { - $issuer = "https://sts.windows.net/$tenantId/" - $expectedAudience = $identifierUri - } - - Write-Host " Token version : v$tokenVersion" - Write-Host " Expected aud : $expectedAudience" - - # Everything the Function needs, in one write. The provider values reach here from three - # different places -- the selection, the security store and the customer -- but they are all - # ordinary app settings by the time the Function reads them. - # - # The key is the exception: it is a Key Vault reference the platform resolves with the managed - # identity, so the private key itself is never stored here. - $appSettings = @{ - EPP_EXPECTED_AUDIENCE = $expectedAudience - EPP_EXPECTED_ISSUER = $issuer - EPP_EXPECTED_CLIENT_ID = $MicrosoftPhoneProviderAppId - EPP_TENANT_ID = $tenantId - EPP_ENCRYPTION_KEY_ID = $keyId - EPP_DECRYPTION_KEY_PEM = "@Microsoft.KeyVault(SecretUri=$secretUri)" - } - - # An omitted int parameter defaults to 0 in PowerShell; distinguish it from a supplied 0. - $providerSettings = Get-ProviderAppSettings -Name $ProviderName -Endpoint $ProviderEndpoint ` - -TimeoutMs $(if ($PSBoundParameters.ContainsKey('ProviderTimeoutMs')) { $ProviderTimeoutMs } else { $null }) ` - -RetryIntervalMs $(if ($PSBoundParameters.ContainsKey('ProviderRetryIntervalMs')) { $ProviderRetryIntervalMs } else { $null }) ` - -AccountName $ProviderAccountName - foreach ($setting in $providerSettings.Keys) { $appSettings[$setting] = $providerSettings[$setting] } - $providerEntraSettings = Get-ProviderEntraSettings -ProviderTenantId $ProviderTenantId -ProviderScope $ProviderScope - $outboundSettings = Ensure-CyotProviderIdentity -FunctionName $FunctionAppName -Group $ResourceGroup ` - -Region $Location -Tag $resourceTag -IdentityName $OutboundIdentityName -Application $application - foreach ($setting in $providerEntraSettings.Keys) { $appSettings[$setting] = $providerEntraSettings[$setting] } - foreach ($setting in $outboundSettings.Keys) { $appSettings[$setting] = $outboundSettings[$setting] } - Write-Host ' Provider auth : Entra token exchange via a user-assigned managed identity; no client secret' - Write-Host ' The deployed package must read EPP_OUTBOUND_MI_CLIENT_ID explicitly; do not set AZURE_CLIENT_ID globally.' -ForegroundColor Yellow - - # --- Application Insights without a usable ingestion key ------------------------------------- - # The connection string cannot be removed -- it carries the ingestion endpoints -- but the - # instrumentation key inside it stops being a credential once local auth is off and telemetry - # has to be published with an Entra token. - $insightsArguments = @('resource', 'show', '--resource-group', $ResourceGroup, '--name', $FunctionAppName, - '--resource-type', 'Microsoft.Insights/components', '--query', 'id', '--output', 'tsv', '--only-show-errors') - $insightsResult = Invoke-AzResult -Arguments $insightsArguments - if ($insightsResult.ExitCode -ne 0 -and ($insightsResult.Lines -join "`n") -notmatch '\bResourceNotFound\b') { - Assert-AzCommandSucceeded -Result $insightsResult -Arguments $insightsArguments - } - - if ($insightsResult.ExitCode -eq 0) { - $insightsId = ($insightsResult.Lines -join '').Trim() - if (-not $insightsId) { throw 'Application Insights lookup returned success without a resource ID.' } - Ensure-AzRoleAssignment -ObjectId $principalId -PrincipalType ServicePrincipal ` - -Role 'Monitoring Metrics Publisher' -Scope $insightsId - - Invoke-Az rest --method patch --url "${insightsId}?api-version=2020-02-02" ` - --body '{\"properties\":{\"DisableLocalAuth\":true}}' ` - --headers 'Content-Type=application/json' -o none --only-show-errors | Out-Null - - $appSettings['APPLICATIONINSIGHTS_AUTHENTICATION_STRING'] = 'Authorization=AAD' - Write-Host ' App Insights : Entra auth, ingestion key disabled' - } - - $written = Set-FunctionAppSettings -Name $FunctionAppName -ResourceGroup $ResourceGroup ` - -SubscriptionId $resolvedSubscriptionId -Settings $appSettings - - Write-Host " App settings : $($appSettings.Count) applied, $written total" - - if (-not $NoEasyAuth) { - # A newly created app starts on auth v1, and every v2 command refuses to run until it is - # upgraded -- including the one below. The upgrade is a no-op on an app already on v2, so it - # is unconditional apart from the check that keeps the log honest. - $authVersion = (Invoke-Az webapp auth config-version show ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --query configVersion -o tsv) - - if ($authVersion -ne 'v2') { - Invoke-Az webapp auth config-version upgrade ` - --name $FunctionAppName ` - --resource-group $ResourceGroup | Out-Null - - Write-Host " Auth config : upgraded $authVersion -> v2" - } - - # allowedApplications is the part that matters and the part that is easy to leave out. - # - # Because assignment is not required on the endpoint service principal, any application in - # this tenant can ask Entra for a token audienced to this endpoint and will get one. Easy - # Auth on its own only proves the token is real and meant for this resource, so without an - # allowed-caller list any internal application could post forged passcodes. Pinning the - # caller to Microsoft's first-party application is what closes that. - $authSettings = @{ - platform = @{ - enabled = $true - runtimeVersion = '~1' - } - globalValidation = @{ - requireAuthentication = $true - - # Must not be RedirectToLoginPage. A 302 carrying an HTML sign-in page is not a 2xx, - # so Microsoft would record a failed delivery and re-send over native telephony. - unauthenticatedClientAction = 'Return401' - } - identityProviders = @{ - azureActiveDirectory = @{ - enabled = $true - registration = @{ - openIdIssuer = $issuer - clientId = $appId - } - validation = @{ - allowedAudiences = @($expectedAudience) - defaultAuthorizationPolicy = @{ - allowedApplications = @($MicrosoftPhoneProviderAppId) - } - } - } - } - - # Nothing here is a sign-in, so there is no token worth storing and no reason to pay for - # the storage round trip on a path with a 3.2 s budget. - login = @{ - tokenStore = @{ enabled = $false } - } - } - - # Written without a byte order mark: the Azure CLI reads @file as UTF-8 and a BOM makes the - # JSON parse fail with an unhelpful error. - $authFile = Join-Path ([System.IO.Path]::GetTempPath()) "epp-auth-$([Guid]::NewGuid()).json" - [System.IO.File]::WriteAllText( - $authFile, - ($authSettings | ConvertTo-Json -Depth 10), - [System.Text.UTF8Encoding]::new($false)) - - try { - # az webapp auth, not az functionapp auth. There is no functionapp equivalent, and the - # microsoft update subcommand cannot express allowedApplications, so the whole v2 - # settings document is written at once. - Invoke-Az webapp auth set ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --body "@$authFile" | Out-Null - } - finally { - Remove-Item $authFile -Force -ErrorAction SilentlyContinue - } - - Write-Host ' Easy Auth : enabled, 401 on anything not from Microsoft' - Write-Host ' Your function trigger must use AuthorizationLevel.Anonymous.' -ForegroundColor Yellow - Write-Host ' Easy Auth is the gate; a function key would only add a secret to the endpoint URL.' - } - else { - Write-Host ' Easy Auth : skipped, validate the bearer token in your own code' -ForegroundColor Yellow - } - - # Resolve where the package comes from. A local file wins, then an explicit URL, then the - # reference package Microsoft publishes — skipped while that is still a placeholder. - $packageToDeploy = $null - $downloadedPackage = $null - - if ($ZipPath) { - if (-not (Test-Path -LiteralPath $ZipPath -PathType Leaf)) { - throw "Zip package not found: $ZipPath" - } - - $packageToDeploy = (Resolve-Path -LiteralPath $ZipPath).Path - } - else { - # A local file wins, then an explicit URL, then the published reference package. The last of - # those is skipped while it is still a placeholder, so an unreleased build provisions - # everything and simply leaves the Function without code rather than failing on a bad host. - $sourceUrl = if ($ZipUrl) { - Read-SetupValue -Name ZipUrl -DefaultValue $ZipUrl -ValueType HttpsUrl -Secret - } - elseif ($ReferencePackageUrl -notmatch '[<>]') { - $ReferencePackageUrl - } - else { - $null - } - - if ($sourceUrl) { - # Zip deploy pushes a local file. Flex Consumption does not honour - # WEBSITE_RUN_FROM_PACKAGE against a URL, so fetching the package here and pushing it is - # the one path that behaves the same on every plan. - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $previousProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - - $downloadedPackage = Join-Path ([System.IO.Path]::GetTempPath()) "epp-package-$([Guid]::NewGuid()).zip" - - # A blob URL usually carries a SAS token. Printing it whole would put a live credential in - # the console and in any transcript, so the query string is masked. - Write-Host " Package : $($sourceUrl -replace '\?.*$', '?')" - - try { - Invoke-WebRequest -Uri $sourceUrl -OutFile $downloadedPackage -UseBasicParsing - } - catch { - throw "Could not download the package. Check the URL and that its SAS token has not expired.`n$($_.Exception.Message)" - } - finally { - $ProgressPreference = $previousProgress - } - - $packageToDeploy = $downloadedPackage - } - } - - if ($packageToDeploy) { - try { - Confirm-SetupAction -Action 'deploy the Function package' -Target $FunctionAppName ` - -Details 'This updates the Function code and writes its deployment package to storage.' - Invoke-Az functionapp deployment source config-zip ` - --name $FunctionAppName ` - --resource-group $ResourceGroup ` - --src $packageToDeploy | Out-Null - - Write-Host ' Deployed : package pushed' - } - finally { - if ($downloadedPackage) { - Remove-Item $downloadedPackage -Force -ErrorAction SilentlyContinue - } - } - } - else { - Write-Host ' No package supplied. Deploy your code before requesting enablement.' -ForegroundColor Yellow - } -} - -# --------------------------------------------------------------------------- -# 10. Tag everything -# --------------------------------------------------------------------------- -# Done as a sweep rather than only at create time. 'az functionapp create' brings up an App Service -# plan and an Application Insights component of its own accord, and neither takes a tag from this -# script, so tagging only what is created explicitly leaves resources the portal will not find. -# Incremental, so any tags a customer already applies are left alone. -if ($provisionFunction -and $StartFromStep -le 10) { - Write-Step 'Tagging resources' - - Invoke-Az tag update ` - --resource-id "/subscriptions/$resolvedSubscriptionId/resourceGroups/$ResourceGroup" ` - --operation Merge --tags $resourceTag --output none | Out-Null - $resourceIds = @(Invoke-Az resource list --resource-group $ResourceGroup --query "[].id" -o tsv) - - foreach ($resourceId in $resourceIds) { - if ([string]::IsNullOrWhiteSpace($resourceId)) { continue } - - $tagArguments = @('resource', 'tag', '--ids', $resourceId, '--tags', $resourceTag, - '--is-incremental', '--output', 'none', '--only-show-errors') - $tagResult = Invoke-AzResult -Arguments $tagArguments - if ($tagResult.ExitCode -ne 0) { - if (Test-AuthenticationFailure ($tagResult.Lines -join "`n")) { - Assert-AzCommandSucceeded -Result $tagResult -Arguments $tagArguments - } - # Some resource types reject tagging. Not worth failing a provisioning run over. - Write-Warning "Could not tag $($resourceId.Split('/')[-1]): $($tagResult.Lines -join ' ')" - } - } - - Write-Host " Tag : $ResourceTagName = $ResourceTagValue" - Write-Host " Applied to : $($resourceIds.Count) resources and the resource group" -} - -# --------------------------------------------------------------------------- -# 11. Summary -# --------------------------------------------------------------------------- -Write-Step 'Stage 2 complete: save these values for policy activation' - -$stageResult = [PSCustomObject]@{ - Stage = 2 - TenantId = $tenantId - EndpointUrl = $EndpointUrl - ApplicationId = $appId - IdentifierUri = $identifierUri - EncryptionKeyId = $keyId - CertThumbprint = $certificate.Thumbprint -} - -if ($provisionFunction) { - Write-Host "Private key is in Key Vault '$KeyVaultName' as 'phone-provider-decryption-key'." -ForegroundColor DarkGray - Write-Host 'No secret is stored in app settings; the Function resolves it with its managed identity.' -ForegroundColor DarkGray - Write-Host '' -} - -Write-Host 'Before requesting enablement, confirm your endpoint:' -ForegroundColor Yellow -Write-Host " 1. rejects any caller that is not $MicrosoftPhoneProviderAppId (Easy Auth, or your own code)" -Write-Host ' 2. decrypts the JWE using the private key named by kid' -Write-Host ' 3. returns 2xx with the SAME nonce it decrypted' -Write-Host ' 4. reads voice passcodes digit by digit' -Write-Host ' 5. responds within 3.2 seconds, delivering asynchronously' -Write-Host 'CYOT policy has not been enabled. Check its live Graph schema in the separate policy-activation stage.' -ForegroundColor Yellow -Write-SetupEvent -Level INFO -Message 'Stage 2 completed successfully. CYOT policy remains disabled pending stage 3.' -$stageSucceeded = $true -} -catch { - Write-SetupFailure -ErrorRecord $_ - if ($script:EventLogPath) { - Write-SetupEvent -Level WARN -Message "After correcting the failure, rerun with the same parameters and -StartFromStep $StartFromStep. Choose an earlier step if the error reports a missing prerequisite." - } - throw -} -finally { - if (-not $stageSucceeded -and $script:EventLogPath) { - Write-SetupEvent -Level WARN -Message 'Stage 2 ended before successful completion. Review the event log and transcript.' - } - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } -} - -if ($script:EventLogPath) { - Write-Host "Event log : $script:EventLogPath" -ForegroundColor DarkGray - Write-Host "Transcript: $script:TranscriptPath" -ForegroundColor DarkGray -} -$stageResult \ No newline at end of file diff --git a/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 b/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 deleted file mode 100644 index 38c8144..0000000 --- a/CYOT-Setup/stages/Step3-Set-CyotPolicy.ps1 +++ /dev/null @@ -1,448 +0,0 @@ -#Requires -Version 7.0 -#Requires -Modules Microsoft.Graph.Authentication - -<# -.SYNOPSIS - Stage 3 of 3: check the live Graph contract, then explicitly activate/update CYOT when supported. -.DESCRIPTION - Run only after app registration, provider purchase/onboarding, and resource provisioning - have completed and the delivery endpoint has been tested. - - The design snapshot proposes authenticationMethodsPolicy.cyot { endpoint, appId, migrated }. - Live public v1.0 and beta metadata checked on 2026-09-14 DID NOT expose that contract. Therefore - this script intentionally refuses a policy write unless a fresh metadata check exposes the - exact supported shape. There is no bypass switch or guessed fallback to externalAuthenticationMethod. - A private-preview contract must be confirmed with CCE/Graph if it differs. - - When supported, sign in to the explicitly selected customer tenant with - Policy.ReadWrite.AuthenticationMethod. Authentication Policy Administrator is the least - privileged supported Entra role. Only the cyot property is patched; no SMS/Voice target lists, - authentication-method states or other policy fields are sent. Save the previous CYOT value, - confirm the operation, detect a changed policy before writing and verify the result afterwards. - This file is self-contained; it does not load or invoke any other setup script. - Only PowerShell and the Microsoft Graph authentication module are required. -.PARAMETER CheckSchemaOnly - Read public Graph metadata and report capability without sign-in, input prompts, backups or writes. -.PARAMETER GraphApiVersion - Public Graph API version to inspect and use. Defaults to beta. No preview URL is invented. -.PARAMETER TenantId - Customer tenant from stages 1 and 2. Required only when the metadata contract is supported. -.PARAMETER ApplicationId - Customer application CLIENT ID returned by stage 1 and reused in stage 2. -.PARAMETER EndpointUrl - Validated delivery endpoint returned by stage 2. -.PARAMETER Migrated - Required explicit path selector from the design: true for migration from native telephony, - false for a new CYOT-only tenant. If omitted, ask at the policy-update step; do not default to live. -.PARAMETER BackupPath - Optional new JSON path for the previous CYOT value. Existing files are never overwritten. - Otherwise a unique timestamped file is written next to this script, only after approval. -.EXAMPLE - .\Step3-Set-CyotPolicy.ps1 -CheckSchemaOnly -.EXAMPLE - .\Step3-Set-CyotPolicy.ps1 -TenantId -ApplicationId -EndpointUrl https://contoso-otp.azurewebsites.net/api/SendOtp -Migrated $true -#> -[CmdletBinding()] -param( - [string] $TenantId, - [string] $ApplicationId, - [string] $EndpointUrl, - [Nullable[bool]] $Migrated, - [ValidateSet('beta', 'v1.0')] - [string] $GraphApiVersion = 'beta', - [string] $BackupPath, - [switch] $CheckSchemaOnly, - [switch] $NonInteractive, - [switch] $ApprovePolicyActivation -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$script:AzureCliContext = $null -$script:GraphTenantId = $TenantId -$script:GraphAccountName = $null -$script:GraphRequiredScopes = @('Policy.ReadWrite.AuthenticationMethod') - -function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } - -function Read-SetupValue { - param( - [string] $Name, - $DefaultValue, - [switch] $Required, - [ValidateSet('String', 'Integer', 'Choice', 'Boolean', 'File', 'HttpsUrl', 'Url', 'StorageName', 'VaultName', 'Guid', 'Scope')] - [string] $ValueType = 'String', - [string[]] $Choices = @(), - [string] $Hint, - [switch] $Secret - ) - - $needsInput = $Required -and - ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) - while ($true) { - $value = $DefaultValue - if ($needsInput -and -not $NonInteractive) { - $prompt = "$Name [required]" - if ($Hint) { $prompt += " - $Hint" } - if ($Choices.Count) { $prompt += " ($($Choices -join ' / '))" } - - if ($Secret) { - $secureValue = Read-Host -Prompt $prompt -AsSecureString - $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) - try { $answer = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } - finally { - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) - $secureValue.Dispose() - } - } - else { $answer = Read-Host -Prompt $prompt } - if (-not [string]::IsNullOrWhiteSpace($answer)) { $value = $answer.Trim() } - } - - $errorText = $null - if ($null -eq $value -or [string]::IsNullOrWhiteSpace("$value")) { - if ($Required) { $errorText = "-$Name is required." } - else { return $null } - } - else { - switch ($ValueType) { - 'Integer' { - $number = 0 - if (-not [int]::TryParse("$value", [ref] $number) -or $number -lt 0) { - $errorText = "-$Name must be a whole number from 0 to $([int]::MaxValue)." - } - else { $value = $number } - } - 'Guid' { - $identifier = [Guid]::Empty - if (-not [Guid]::TryParse("$value", [ref] $identifier) -or $identifier -eq [Guid]::Empty) { - $errorText = "-$Name must be a nonempty GUID." - } - else { $value = $identifier.ToString('D') } - } - 'Scope' { - $resource = "$value" -replace '/\.default$', '' - $resourceId = [Guid]::Empty - $resourceUri = $null - $isGuid = [Guid]::TryParse($resource, [ref] $resourceId) - $isUri = [Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) - if ("$value" -notmatch '/\.default$' -or - ($isGuid -and $resourceId -eq [Guid]::Empty) -or - (-not $isGuid -and (-not $isUri -or $resourceUri.Scheme -notin @('api', 'https') -or - $resourceUri.Query -or $resourceUri.Fragment -or $resourceUri.UserInfo -or -not $resourceUri.Host)) -or - "$value" -match '\s') { - $errorText = "-$Name must be the provider API's App ID URI or application ID followed by /.default." - } - } - 'Boolean' { - if ("$value" -match '^(?i:y|yes|true)$') { $value = $true } - elseif ("$value" -match '^(?i:n|no|false)$') { $value = $false } - else { $errorText = "-$Name must be Yes or No." } - } - 'Choice' { - if ($Choices -notcontains "$value") { $errorText = "-$Name must be one of: $($Choices -join ', ')." } - else { $value = $Choices | Where-Object { $_ -eq "$value" } | Select-Object -First 1 } - } - 'File' { - if (-not (Test-Path -LiteralPath "$value" -PathType Leaf)) { $errorText = "-$Name must point to an existing file." } - } - { $_ -in @('HttpsUrl', 'Url') } { - $parsedUri = $null - if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or - $parsedUri.Scheme -notin @('http', 'https') -or - ($ValueType -eq 'HttpsUrl' -and $parsedUri.Scheme -ne 'https')) { - $errorText = "-$Name must be an absolute $($ValueType -eq 'HttpsUrl' ? 'HTTPS' : 'HTTP or HTTPS') URL." - } - } - 'StorageName' { - if ("$value" -cnotmatch '^[a-z0-9]{3,24}$') { $errorText = '-StorageAccountName must be 3-24 lowercase letters or digits.' } - } - 'VaultName' { - if ("$value" -notmatch '^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$' -or "$value" -match '--') { - $errorText = '-KeyVaultName must be 3-24 letters, digits or single hyphens, start with a letter and end with a letter or digit.' - } - } - } - } - - if (-not $errorText) { return $value } - if (-not $needsInput -or $NonInteractive) { throw $errorText } - Write-Warning $errorText - } -} - -function Confirm-SetupAction { - param([string] $Action, [string] $Target, [string] $Details) - - if ($NonInteractive) { - if ($ApprovePolicyActivation) { - Write-Host " Approval : explicitly supplied for noninteractive policy activation" -ForegroundColor Yellow - return - } - throw "Approval required to $Action '$Target'. Supply -ApprovePolicyActivation or rerun without -NonInteractive; no automatic approval is assumed." - } - Write-Host "`n Approval: $Action '$Target'" -ForegroundColor Yellow - if ($script:AzureCliContext) { - Write-Host " Subscription: $($script:AzureCliContext.name) ($($script:AzureCliContext.id))" - } - if ($Details) { Write-Host " $Details" } - while ($true) { - $answer = [string](Read-Host -Prompt 'Proceed? Type Yes to approve [y/N]') - $answer = $answer.Trim() - if ($answer -match '^(?i:y|yes)$') { return } - if (-not $answer -or $answer -match '^(?i:n|no)$') { - throw [OperationCanceledException]::new("Setup cancelled before attempting to $Action '$Target'. Previously completed changes are left in place.") - } - Write-Warning 'Enter Yes to approve, or No/empty to stop.' - } -} - -function Test-AuthenticationFailure { - param([string] $Message) - - # Do not retry authorization failures (403), policy blocks, network errors or invalid arguments. - return $Message -match ('(?i)Status_InteractionRequired|interaction_required|MsalUiRequiredException|' + - 'AuthenticationRequiredException|Authentication_ExpiredToken|InvalidAuthenticationToken|' + - 'ExpiredAuthenticationToken|AADSTS(?:50058|50076|50078|50079|50173|65001|70043|700082|700084)\b|' + - '(?:access|refresh) token (?:has |is )?expired|Please explicitly log in|' + - '\brun:?\s+[''"`]?az login\b|Can''t find token from MSAL cache|' + - 'Connect-MgGraph.*must be called|Authentication needed\.\s*Please call Connect-MgGraph') -} - -function Connect-EndpointGraph { - param([switch] $Reconnect, [string[]] $Scopes) - - if ($PSBoundParameters.ContainsKey('Scopes')) { - if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { - throw 'Graph authentication requires at least one nonempty scope.' - } - $script:GraphRequiredScopes = $Scopes - } - - $context = Get-MgContext -ErrorAction Stop - $canReuse = $context -and $context.AuthType -eq 'Delegated' -and - $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and - $context.Environment -eq 'Global' -and - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and - (-not $script:GraphTenantId -or $context.TenantId -eq $script:GraphTenantId) - - if ($Reconnect -or -not $canReuse) { - if ($NonInteractive) { - throw "Microsoft Graph PowerShell needs sign-in with $($script:GraphRequiredScopes -join ', ') in the target tenant. Connect-MgGraph first, or rerun without -NonInteractive." - } - $connectParameters = @{ - Scopes = $script:GraphRequiredScopes - ContextScope = 'Process' - Environment = 'Global' - NoWelcome = $true - ErrorAction = 'Stop' - } - if ($script:GraphTenantId) { $connectParameters['TenantId'] = $script:GraphTenantId } - Write-Host ' Graph sign-in: complete any consent/MFA prompt for Microsoft Graph PowerShell.' -ForegroundColor Yellow - Connect-MgGraph @connectParameters | Out-Null - $context = Get-MgContext -ErrorAction Stop - } - - if (-not $context -or $context.AuthType -ne 'Delegated' -or - $context.Environment -ne 'Global' -or - @($script:GraphRequiredScopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or - ($script:GraphTenantId -and $context.TenantId -ne $script:GraphTenantId) -or - ($script:GraphAccountName -and $context.Account -ne $script:GraphAccountName)) { - throw 'Microsoft Graph sign-in has the wrong tenant, account or permissions. Use the original Graph account in the target tenant.' - } - $script:GraphTenantId = $context.TenantId - $script:GraphAccountName = $context.Account -} - -function Invoke-EndpointGraph { - param([scriptblock] $Operation) - - try { - & $Operation - } - catch { - $exception = $_.Exception - $authenticationFailure = Test-AuthenticationFailure ($_ | Out-String) - while ($exception) { - if ($exception.GetType().Name -in @('MsalUiRequiredException', 'AuthenticationRequiredException') -or - ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode -eq 401) -or - ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode -eq 401)) { - $authenticationFailure = $true - } - $exception = $exception.InnerException - } - if (-not $authenticationFailure) { throw } - Write-Host ' Graph auth : renewing the SDK session; retrying the operation once' -ForegroundColor Yellow - Connect-EndpointGraph -Reconnect - & $Operation - } -} - - -function Get-CyotGraphSchemaStatus { - param([ValidateSet('beta', 'v1.0')] [string] $ApiVersion) - - $metadataUri = "https://graph.microsoft.com/$ApiVersion/`$metadata" - $response = Invoke-WebRequest -Uri $metadataUri -TimeoutSec 60 -ErrorAction Stop - $readerSettings = [Xml.XmlReaderSettings]::new() - $readerSettings.DtdProcessing = [Xml.DtdProcessing]::Prohibit - $readerSettings.XmlResolver = $null - $textReader = [IO.StringReader]::new([string]$response.Content) - $reader = [Xml.XmlReader]::Create($textReader, $readerSettings) - $document = [Xml.XmlDocument]::new() - $document.XmlResolver = $null - try { $document.Load($reader) } - finally { $reader.Dispose(); $textReader.Dispose() } - $ns = [Xml.XmlNamespaceManager]::new($document.NameTable) - $ns.AddNamespace('edm', 'http://docs.oasis-open.org/odata/ns/edm') - $schema = $document.SelectSingleNode('//edm:Schema[@Namespace="microsoft.graph"]', $ns) - if (-not $schema) { throw "Graph metadata at $metadataUri does not contain the microsoft.graph schema." } - $policyType = $schema.SelectSingleNode('edm:EntityType[@Name="authenticationMethodsPolicy"]', $ns) - if (-not $policyType) { throw 'The Graph schema does not contain authenticationMethodsPolicy.' } - - $property = $null - $visited = @{} - while ($policyType -and -not $property) { - $typeName = $policyType.GetAttribute('Name') - if ($visited.ContainsKey($typeName)) { throw 'Graph metadata contains cyclic policy inheritance.' } - $visited[$typeName] = $true - $property = $policyType.SelectSingleNode('edm:Property[@Name="cyot"]', $ns) - $baseName = ($policyType.GetAttribute('BaseType') -split '\.')[-1] - $policyType = $schema.SelectSingleNode("edm:EntityType[@Name='$baseName']", $ns) - } - - $supported = $false - $reason = 'authenticationMethodsPolicy.cyot is not declared in the live Graph schema.' - if ($property) { - $qualifiedType = $property.GetAttribute('Type') - $typeName = ($qualifiedType -split '\.')[-1] - $allowedPrefixes = @('microsoft.graph') - if ($schema.GetAttribute('Alias')) { $allowedPrefixes += $schema.GetAttribute('Alias') } - $prefix = $qualifiedType.Substring(0, [Math]::Max(0, $qualifiedType.Length - $typeName.Length - 1)) - $configuration = $schema.SelectSingleNode("edm:ComplexType[@Name='$typeName']", $ns) - $reason = 'The cyot property is present, but its type does not match the supported endpoint/appId/migrated contract.' - if ($configuration -and $allowedPrefixes -contains $prefix) { - $fields = @($configuration.SelectNodes('edm:Property', $ns)) - $expected = @{ endpoint = 'Edm.String'; appId = 'Edm.String'; migrated = 'Edm.Boolean' } - $supported = $fields.Count -eq 3 -and -not $configuration.HasAttribute('BaseType') -and - $configuration.GetAttribute('OpenType') -ne 'true' - foreach ($name in $expected.Keys) { - $field = $configuration.SelectSingleNode("edm:Property[@Name='$name']", $ns) - if (-not $field -or $field.GetAttribute('Type') -ne $expected[$name]) { $supported = $false } - } - if ($supported) { $reason = 'The declared CYOT property matches endpoint, appId and migrated. Tenant authorization and feature availability are still required.' } - } - } - return [PSCustomObject]@{ - ApiVersion = $ApiVersion - MetadataUri = $metadataUri - PolicyUri = "https://graph.microsoft.com/$ApiVersion/policies/authenticationMethodsPolicy" - Supported = $supported - Reason = $reason - CheckedAtUtc = [DateTime]::UtcNow.ToString('o') - } -} - -function Get-CyotPolicyState { - param($Policy) - - if (-not $Policy -or -not $Policy.PSObject.Properties['cyot'] -or $null -eq $Policy.cyot) { return $null } - $configuration = $Policy.cyot - $unknown = @($configuration.PSObject.Properties.Name | Where-Object { - $_ -notin @('endpoint', 'appId', 'migrated', '@odata.type') - }) - if ($unknown.Count) { throw "Existing CYOT policy contains unsupported fields: $($unknown -join ', '). Nothing will be overwritten." } - if (-not $configuration.PSObject.Properties['endpoint'] -or -not $configuration.PSObject.Properties['appId']) { - throw 'Existing CYOT policy is missing its endpoint or appId. Resolve the policy contract before updating.' - } - return [ordered]@{ - endpoint = $configuration.endpoint - appId = $configuration.appId - migrated = $(if ($configuration.PSObject.Properties['migrated']) { $configuration.migrated } else { $null }) - } -} - -function Invoke-CyotPolicyUpdate { - param( - $SchemaStatus, [string] $CustomerTenantId, [string] $ClientId, - [string] $DeliveryEndpoint, [Nullable[bool]] $MigrationPath, [string] $SnapshotPath - ) - - if (-not $SchemaStatus.Supported) { - throw "$($SchemaStatus.Reason) No policy change was attempted. Ask CCE/Graph for the supported preview contract before activation." - } - $CustomerTenantId = Read-SetupValue -Name TenantId -DefaultValue $CustomerTenantId -Required -ValueType Guid - $script:GraphTenantId = $CustomerTenantId - Connect-EndpointGraph -Scopes @('Policy.ReadWrite.AuthenticationMethod') - $current = Invoke-EndpointGraph { - Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop - } - $previous = Get-CyotPolicyState -Policy $current - - $ClientId = Read-SetupValue -Name ApplicationId -DefaultValue $ClientId -Required -ValueType Guid - $DeliveryEndpoint = Read-SetupValue -Name EndpointUrl -DefaultValue $DeliveryEndpoint -Required -ValueType HttpsUrl - $uri = [Uri]::new($DeliveryEndpoint) - if ($uri.IsLoopback -or $uri.HostNameType -in @('IPv4', 'IPv6') -or $uri.UserInfo -or $uri.Fragment) { - throw 'The CYOT endpoint must use a public HTTPS hostname without embedded credentials or a fragment.' - } - $MigrationPath = Read-SetupValue -Name Migrated -DefaultValue $MigrationPath -Required -ValueType Boolean ` - -Hint 'Yes: migrate from native telephony. No: new CYOT-only tenant. Choose deliberately.' - $desired = [ordered]@{ endpoint = $DeliveryEndpoint; appId = $ClientId; migrated = [bool]$MigrationPath } - $previousJson = ConvertTo-Json -InputObject $previous -Depth 10 -Compress - $desiredJson = ConvertTo-Json -InputObject $desired -Depth 10 -Compress - if ($previousJson -ceq $desiredJson) { - Write-Host ' CYOT policy : already matches; no update or approval needed' - return [PSCustomObject]@{ Stage = 3; TenantId = $CustomerTenantId; Updated = $false; PolicyUri = $SchemaStatus.PolicyUri } - } - - $body = @{ cyot = $desired } | ConvertTo-Json -Depth 10 - Confirm-SetupAction -Action 'update CYOT authentication policy' -Target $CustomerTenantId ` - -Details "This can change SMS/Voice sign-in routing. Confirm the provider is purchased, onboarded and the stage-2 endpoint tested. Only this property will be patched:`n$body" - $latest = Invoke-EndpointGraph { - Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop - } - $latestJson = ConvertTo-Json -InputObject (Get-CyotPolicyState -Policy $latest) -Depth 10 -Compress - if ($latestJson -cne $previousJson) { - throw 'CYOT policy changed while awaiting approval. No PATCH was sent; rerun to review the new state.' - } - - if ([string]::IsNullOrWhiteSpace($SnapshotPath)) { - $SnapshotPath = Join-Path $PSScriptRoot "cyot-policy-before-$CustomerTenantId-$([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'))-$([Guid]::NewGuid().ToString('N')).json" - } - $snapshot = @{ - TenantId = $CustomerTenantId; PolicyUri = $SchemaStatus.PolicyUri - SavedAtUtc = [DateTime]::UtcNow.ToString('o'); PreviousCyot = $previous - } | ConvertTo-Json -Depth 10 - $stream = [IO.File]::Open($SnapshotPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) - try { - $bytes = [Text.UTF8Encoding]::new($false).GetBytes($snapshot) - $stream.Write($bytes, 0, $bytes.Length) - } - finally { $stream.Dispose() } - Write-Host " Policy backup : $SnapshotPath" - $headers = @{} - if ($latest.PSObject.Properties['@odata.etag']) { $headers['If-Match'] = $latest.'@odata.etag' } - Invoke-EndpointGraph { - Invoke-MgGraphRequest -Method PATCH -Uri $SchemaStatus.PolicyUri -Body $body ` - -ContentType 'application/json' -Headers $headers -ErrorAction Stop - } | Out-Null - $after = Invoke-EndpointGraph { - Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop - } - if ((ConvertTo-Json -InputObject (Get-CyotPolicyState -Policy $after) -Depth 10 -Compress) -cne $desiredJson) { - throw "Graph accepted the update but readback does not match. Do not assume CYOT is active. Previous value: $SnapshotPath" - } - return [PSCustomObject]@{ - Stage = 3; TenantId = $CustomerTenantId; ApplicationId = $ClientId; EndpointUrl = $DeliveryEndpoint - Migrated = [bool]$MigrationPath; Updated = $true; PolicyUri = $SchemaStatus.PolicyUri; BackupPath = $SnapshotPath - } -} - -Write-Step 'Stage 3: checking the live CYOT Graph contract' -$schemaStatus = Get-CyotGraphSchemaStatus -ApiVersion $GraphApiVersion -if ($CheckSchemaOnly) { - $schemaStatus - return -} -Invoke-CyotPolicyUpdate -SchemaStatus $schemaStatus -CustomerTenantId $TenantId -ClientId $ApplicationId ` - -DeliveryEndpoint $EndpointUrl -MigrationPath $Migrated -SnapshotPath $BackupPath diff --git a/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 b/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 deleted file mode 100644 index 480b1b1..0000000 --- a/CYOT-Setup/tests/Setup-Cyot.SmokeTests.ps1 +++ /dev/null @@ -1,134 +0,0 @@ -#Requires -Version 7.0 - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$packageRoot = Split-Path -Parent $PSScriptRoot -$entryPoint = Join-Path $packageRoot 'Setup-Cyot.ps1' -$failures = [Collections.Generic.List[string]]::new() - -function Invoke-TestProcess { - param([string[]] $Arguments) - - $output = & (Get-Command pwsh -ErrorAction Stop).Source -NoProfile -File $entryPoint @Arguments 2>&1 | Out-String - return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } -} - -function Test-Condition { - param([string] $Name, [bool] $Condition, [string] $Detail) - - if ($Condition) { - Write-Host "PASS: $Name" -ForegroundColor Green - return - } - $failures.Add("${Name}: $Detail") - Write-Host "FAIL: $Name - $Detail" -ForegroundColor Red -} - -$testRoot = Join-Path ([IO.Path]::GetTempPath()) "cyot-smoke-$([Guid]::NewGuid().ToString('N'))" -try { - New-Item -ItemType Directory -Path $testRoot -Force | Out-Null - - $diagnostics = Invoke-TestProcess -Arguments @('-Stage', 'Diagnostics', '-StatePath', (Join-Path $testRoot 'diagnostics-state.json')) - Test-Condition 'Diagnostics completes locally' ($diagnostics.ExitCode -eq 0 -and $diagnostics.Output -match 'Diagnostics completed') $diagnostics.Output - - $invalidConfigPath = Join-Path $testRoot 'invalid.json' - [IO.File]::WriteAllText($invalidConfigPath, '{ invalid json', [Text.UTF8Encoding]::new($false)) - $invalidConfig = Invoke-TestProcess -Arguments @('-Stage', 'Diagnostics', '-ConfigPath', $invalidConfigPath, '-StatePath', (Join-Path $testRoot 'invalid-state.json')) - Test-Condition 'Invalid JSON is rejected' ($invalidConfig.ExitCode -ne 0 -and $invalidConfig.Output -match 'JSON') $invalidConfig.Output - - $activationStatePath = Join-Path $testRoot 'activation-state.json' - @{ - schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') - tenantId = '11111111-1111-1111-1111-111111111111' - applicationId = '22222222-2222-2222-2222-222222222222' - endpointUrl = 'https://example.com/api/SendOtp' - graphSchemaSupported = $true; policyUpdated = $false - completedStages = @('Register', 'Deploy', 'Validate') - } | ConvertTo-Json | Set-Content -LiteralPath $activationStatePath -Encoding utf8NoBOM - $activation = Invoke-TestProcess -Arguments @('-Stage', 'Activate', '-NonInteractive', '-StatePath', $activationStatePath) - Test-Condition 'Noninteractive activation requires explicit approval' ` - ($activation.ExitCode -ne 0 -and $activation.Output -match 'requires -ApprovePolicyActivation') $activation.Output - - $temporaryPackage = Join-Path $testRoot 'package' - New-Item -ItemType Directory -Path (Join-Path $temporaryPackage 'stages') -Force | Out-Null - Copy-Item -LiteralPath $entryPoint -Destination (Join-Path $temporaryPackage 'Setup-Cyot.ps1') - $temporaryEntryPoint = Join-Path $temporaryPackage 'Setup-Cyot.ps1' - $entryPoint = $temporaryEntryPoint - - $missingStage = Invoke-TestProcess -Arguments @('-Stage', 'Register', '-NonInteractive', '-StatePath', (Join-Path $testRoot 'missing-stage-state.json')) - Test-Condition 'Missing stage is reported before execution' ` - ($missingStage.ExitCode -ne 0 -and $missingStage.Output -match 'Packaged Register stage script is missing') $missingStage.Output - - @' -[CmdletBinding()] -param([string] $TenantId, [string] $ApplicationId, [string] $DisplayName, [switch] $NonInteractive, [switch] $SkipAzureLogin, [string] $LogDirectory) -'33333333-3333-3333-3333-333333333333' -'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Step1-Register-CyotApplication.ps1') -Encoding utf8NoBOM - $configPath = Join-Path $testRoot 'customer.json' - @{ setup = @{ tenantId = '11111111-1111-1111-1111-111111111111' }; registration = @{ skipAzureLogin = $true } } | - ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configPath -Encoding utf8NoBOM - $statePath = Join-Path $testRoot 'state/cyot.json' - $registration = Invoke-TestProcess -Arguments @('-Stage', 'Register', '-NonInteractive', '-ConfigPath', $configPath, '-StatePath', $statePath) - $state = if (Test-Path -LiteralPath $statePath) { Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json } else { $null } - Test-Condition 'Registration output is normalized and state is saved' ` - ($registration.ExitCode -eq 0 -and $state.applicationId -eq '33333333-3333-3333-3333-333333333333' -and $state.completedStages -contains 'Register') $registration.Output - - @' -[CmdletBinding()] -param([string] $SubscriptionId, [string] $ResourceGroup, [string] $Location, [string] $EnvironmentName, [string] $PlanType, [switch] $NonInteractive) -[pscustomobject]@{ - Stage = 'Infrastructure'; SubscriptionId = $SubscriptionId; ResourceGroup = $ResourceGroup; Location = $Location - PlanType = $PlanType; FunctionAppName = 'cyot-prod-func-test'; StorageAccountName = 'cyotprodstoragetest'; KeyVaultName = 'cyot-prod-kv-test' -} -'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Deploy-CyotInfrastructure.ps1') -Encoding utf8NoBOM - @' -[CmdletBinding()] -param([string] $ApplicationId, [string] $LogDirectory, [string] $SubscriptionId, [string] $ResourceGroup, [string] $Location, - [string] $FunctionAppName, [string] $StorageAccountName, [string] $KeyVaultName, [string] $PlanType, [switch] $NonInteractive) -[pscustomobject]@{ - Stage = 2; TenantId = '11111111-1111-1111-1111-111111111111'; EndpointUrl = "https://$FunctionAppName.azurewebsites.net/api/SendOtp" - ApplicationId = $ApplicationId; IdentifierUri = "api://$ApplicationId"; EncryptionKeyId = 'test-key'; CertThumbprint = 'TEST' -} -'@ | Set-Content -LiteralPath (Join-Path $temporaryPackage 'stages/Step2-Setup-ExternalPhoneProvider.ps1') -Encoding utf8NoBOM - $bicepConfigPath = Join-Path $testRoot 'bicep.json' - @{ - endpoint = @{ - infrastructureMode = 'Bicep'; subscriptionId = '44444444-4444-4444-4444-444444444444' - resourceGroup = 'rg-cyot-test'; location = 'eastus'; environmentName = 'prod'; planType = 'Premium' - } - } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $bicepConfigPath -Encoding utf8NoBOM - $bicepStatePath = Join-Path $testRoot 'bicep-state.json' - @{ - schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') - applicationId = '33333333-3333-3333-3333-333333333333'; completedStages = @('Register') - } | ConvertTo-Json | Set-Content -LiteralPath $bicepStatePath -Encoding utf8NoBOM - $bicepDeploy = Invoke-TestProcess -Arguments @('-Stage', 'Deploy', '-NonInteractive', '-ConfigPath', $bicepConfigPath, '-StatePath', $bicepStatePath) - $bicepState = if (Test-Path -LiteralPath $bicepStatePath) { Get-Content -LiteralPath $bicepStatePath -Raw | ConvertFrom-Json } else { $null } - Test-Condition 'Bicep outputs are forwarded and persisted for resume' ` - ($bicepDeploy.ExitCode -eq 0 -and $bicepState.functionAppName -eq 'cyot-prod-func-test' -and - $bicepState.storageAccountName -eq 'cyotprodstoragetest' -and $bicepState.keyVaultName -eq 'cyot-prod-kv-test' -and - $bicepState.planType -eq 'Premium' -and $bicepState.completedStages -contains 'Deploy') $bicepDeploy.Output - - Remove-Item -LiteralPath (Join-Path $temporaryPackage 'stages/Step2-Setup-ExternalPhoneProvider.ps1') -Force - $resumeStatePath = Join-Path $testRoot 'resume-state.json' - @{ - schemaVersion = 1; updatedAtUtc = [DateTime]::UtcNow.ToString('o') - tenantId = '11111111-1111-1111-1111-111111111111' - applicationId = '33333333-3333-3333-3333-333333333333' - completedStages = @('Register') - } | ConvertTo-Json | Set-Content -LiteralPath $resumeStatePath -Encoding utf8NoBOM - $resume = Invoke-TestProcess -Arguments @('-Resume', '-NonInteractive', '-StatePath', $resumeStatePath) - Test-Condition 'Resume starts with the first incomplete stage' ` - ($resume.ExitCode -ne 0 -and $resume.Output -match 'Packaged Deploy stage script is missing' -and $resume.Output -notmatch 'Packaged Register stage script is missing') $resume.Output -} -finally { - Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -if ($failures.Count) { - throw "Smoke tests failed:`n$($failures -join "`n")" -} -Write-Host 'All CYOT setup smoke tests passed.' -ForegroundColor Green -exit 0 \ No newline at end of file diff --git a/README.md b/README.md index 21951ee..da31bad 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,19 @@ by default. Deploy each language separately, not all three to the same Function New here? Start with **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — setup, config, running, securing, and deploying, step by step. -## Guided CYOT setup - -Use **[CYOT-Setup](CYOT-Setup/docs/README.md)** for a PowerShell-guided setup that registers the -customer application, provisions or connects an External Phone Provider endpoint, validates the -configuration, and activates the CYOT policy only after explicit approval. The setup supports Bicep -or Azure CLI provisioning, redacted logs, diagnostics, and resumable stages. +## Guided EPP setup + +Use **[setup](setup/docs/README.md)** for **Step 2: endpoint deployment**. Download only +`Setup-Epp.ps1`; it downloads its supporting tools, Bicep, and provider JSON from GitHub. Supply +missing customer settings, select **JavaScript, .NET, or Python**, choose Telesign or Soprano, +**SMS or voice**, **Global or EU**, enter a resource prefix, and approve one complete resource plan. +Generated names add `epp` after the customer prefix. Missing required Azure resource providers +are registered automatically after approval. Package links and published checksums are +selected automatically. Setup publishes .NET for Linux and requests Azure remote build for Python; +customers do not build or deploy the source ZIPs manually. The .NET choice requires the .NET 8 SDK. +Application registration (Step 1) and policy activation (Step 3) remain manual. Missing provider +details are explicitly labelled test values and written to the real Function App settings; replace +them and complete Telesign API-key or Soprano OAuth onboarding before live delivery. ## Download a Function ZIP @@ -38,9 +45,9 @@ Download the preview ZIP for your chosen language: | Language | Download | Contents | |---|---|---| -| JavaScript | [epp-javascript.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-javascript.zip) | Application and production dependencies | -| .NET | [epp-dotnet-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-dotnet-source-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | -| Python | [epp-python-source.zip](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-packages-preview-20260914/epp-python-source.zip) | Source for Azure remote build on Linux | +| JavaScript | [epp-javascript.zip](https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-javascript.zip) | Application and production dependencies | +| .NET | [epp-dotnet-source.zip](https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-dotnet-source.zip) | C# Function source and project file; build/publish before deployment | +| Python | [epp-python-source.zip](https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-python-source.zip) | Source for Azure remote build on Linux | Customers do not need PowerShell or a local build toolchain to download these files. Verify downloads against the corresponding release's `SHA256SUMS.txt`. Configure the target Function App's runtime, app settings, @@ -49,14 +56,14 @@ project; Python requires remote build to install dependencies. Neither source ZI as a run-from-package artifact. GitHub's **Code > Download ZIP** is the whole source repository, not a Function deployment package. -After the packaging workflow is merged, each successful `main` build tests all three implementations, +The private test links above match `test/epp-single-script`. After the packaging workflow is merged +upstream, each successful `main` build tests all three implementations, builds and inspects the ZIPs, and publishes a new versioned release. Get those builds from [Latest release](https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/releases/latest). Older releases remain available; existing assets are not overwritten. Pull requests build downloadable workflow artifacts only and cannot publish releases. GitHub sign-in may be required for workflow artifacts, but public release downloads do not require a local build. Packaging does not deploy or -verify live provider delivery. The current preview is built from the packaging branch, not a merged -release of the separate provider feature branches. +verify live provider delivery. ## Build ZIPs Locally @@ -113,7 +120,7 @@ extend it deliberately if you add runtime assets, and never put secrets in appli ## The design in one line SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → -configured provider (API key) → HTTP result with nonce on success. +configured provider (Telesign API key or Soprano OAuth) → HTTP result with nonce on success. Only provider acceptance returns the nonce for live requests. Incoming `mode: 2` (evaluation) is the generic shutter: after platform authentication, validate and decrypt, then echo the nonce without calling a provider. @@ -148,7 +155,12 @@ how code accesses configuration, not the environment-variable names. | `EPP_DECRYPTION_KEY_PEM` | Every request | Local test PEM or base64 PEM. In Azure, use a Key Vault reference resolving to the private-key secret. | | `EPP_ENCRYPTION_KEY_ID` | Optional | Expected encryption key ID; mismatch only produces an advisory warning. | | `EPP_PROVIDER_NAME` | Live delivery | Selected adapter's manifest ID. No default provider. | -| `EPP_PROVIDER_ENDPOINT` | Live delivery | HTTPS **base URL**, in the same environment as the provider credentials; the adapter adds its route. | +| `EPP_PROVIDER_ENDPOINT` | Live delivery | Complete provider-approved HTTPS request URL for the selected channel and endpoint region. | +| `EPP_PROVIDER_CHANNEL` | Guided deployment | Selected `sms` or `voice` route; live requests for the other channel fail closed. | +| `EPP_PROVIDER_ENDPOINT_REGION` | Guided deployment metadata | Selected `global` or `eu` route label. | +| `EPP_PROVIDER_AUTH_MODE` | Live delivery | Must match the adapter: `apiKey` for Telesign or `oauth` for Soprano. | +| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth | Provider tenant and selected API scope. | +| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Soprano OAuth | Existing multitenant application client ID and outbound user-assigned managed identity client ID used for client-assertion exchange. | | `EPP_PROVIDER_TIMEOUT_MS` | Optional | Decimal milliseconds. Defaults to `1500`, capped at `2500`; not an end-to-end deadline. | | `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-dependent | Sender/account metadata, not an API key or credential identity. | | `KEY_VAULT_URL` | Provider credential lookup | URI of the vault containing the manifest-named provider secrets. Separate from the encryption-key reference. | @@ -160,9 +172,9 @@ how code accesses configuration, not the environment-variable names. 2. **In Azure:** set the same application variables on the selected Function App (or serving slot) under **Settings → Environment variables → App settings**, then apply the changes. Local settings are not published automatically. Configure host storage separately for the selected hosting plan. -3. Store provider API keys and any required identity secrets in Key Vault using the **exact names in - the adapter manifest**. Grant that app/slot's managed identity *Key Vault Secrets User* on those - secrets. An API key in a local environment variable is not a supported replacement for the resolver. +3. For Telesign, store provider API credentials in Key Vault using the **exact names in the adapter + manifest** and grant the Function identity *Key Vault Secrets User*. For Soprano, configure the + provider tenant/scope and outbound managed-identity federation; no provider secret is stored. Evaluation requests do not need provider variables or provider secrets. They still need the decryption key. The default credential resolvers use `ManagedIdentityCredential`, **not** the developer's CLI diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 01ab72d..5794d38 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -151,8 +151,9 @@ success-looking status. Explicit `Block`/`StepUp` outcomes remain non-success re Each provider is one unit exposing three things: - **`manifest`** — protocol facts only: - - `id` — provider id selected by `EPP_PROVIDER_NAME`; its base URL is `EPP_PROVIDER_ENDPOINT` - - `auth` — `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }`; other modes fail closed + - `id` — provider id selected by `EPP_PROVIDER_NAME`; its complete request URL is `EPP_PROVIDER_ENDPOINT` + - `auth` — either `{ mode: 'apiKey', keyVaultSecretName, identityKeyVaultSecretName? }` or + `{ mode: 'oauth' }`; unsupported modes fail closed - `responseMapping` — map of provider status → `Continue` | `Fail` | `Block` | `StepUp` (+ `default`) - **`buildRequest({ channel, endpoint, dispatch, credential, env })`** → `{ url, method, headers, body }` - **`parseResponse({ httpStatus, ok, json })`** → `ParsedResponse`, containing `success`, @@ -186,12 +187,17 @@ Set by provisioning. **Identical names across all languages.** | Key | Purpose | |-----|---------| | `EPP_PROVIDER_NAME` | registered id of the selected provider; `` is a placeholder, not a bundled default | -| `EPP_PROVIDER_ENDPOINT` | absolute HTTPS base URL with a hostname, port 1–65535, and no userinfo or fragment; the final adapter URL is also validated; redirects are not followed | +| `EPP_PROVIDER_ENDPOINT` | complete absolute HTTPS request URL for the selected channel/region, with a hostname, port 1–65535, and no userinfo or fragment; redirects are not followed | +| `EPP_PROVIDER_CHANNEL` | optional configured `sms` or `voice` route; when set, other live-request channels fail closed | +| `EPP_PROVIDER_ENDPOINT_REGION` | selected `global` or `eu` route label; informational at runtime | +| `EPP_PROVIDER_AUTH_MODE` | must match the selected adapter (`apiKey` for Telesign, `oauth` for Soprano) | +| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano provider tenant and OAuth scope | +| `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | client application and user-assigned identity used for Soprano client-assertion exchange | | `EPP_PROVIDER_ACCOUNT_NAME` | sender/source only when required by the selected adapter | | `EPP_PROVIDER_TIMEOUT_MS` | trimmed ASCII decimal milliseconds; default 1500 for missing/invalid/nonpositive values; capped at 2500. Not a whole-invocation deadline | | `EPP_DECRYPTION_KEY_PEM` | single RSA private key for JWE decryption, PEM or base64-encoded PEM; use a Key Vault secret reference in Azure, not a plaintext private key in shared settings | | `EPP_ENCRYPTION_KEY_ID` | optional expected JWE `kid`; after successful decryption, a mismatch emits only `encryption_key_id_mismatch`. Advisory, not a key selector or authentication check | -| `KEY_VAULT_URL` | Key Vault URI (provider API keys) | +| `KEY_VAULT_URL` | Key Vault URI for API-key providers | | `AZURE_CLIENT_ID` | set for a user-assigned managed identity | Provider credential values live in **Key Vault**, under the names in the selected adapter's manifest, @@ -206,9 +212,11 @@ guard or backup token validation. See [platform onboarding](ONBOARDING.md#2-prov ### Default provider and configuration readers -Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus that account's -`EPP_PROVIDER_ENDPOINT` and Key Vault credentials. A missing or unknown provider fails closed; -there is no implicit default or automatic failover. Request-body provider fields are not used. +Provision `EPP_PROVIDER_NAME` with the customer's selected provider, plus the complete selected +channel/region `EPP_PROVIDER_ENDPOINT` and matching authentication settings. Telesign resolves its +API-key credentials from Key Vault. Soprano exchanges an outbound managed-identity assertion for a +token in the configured provider tenant/scope. A missing or unknown provider fails closed; there is +no implicit default or automatic failover. Request-body provider fields are not used. The shared configuration readers are [JavaScript `readConfig`](../javascript/src/functions/config.js), [Python `read_config`](../python/src/config.py), and [.NET `AppConfig.Read`](../dotnet/Src/AppConfig.cs). diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index a10d438..8330756 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -7,32 +7,36 @@ define required credentials and options. No provider is preferred or selected by ## 1. Select and configure an adapter -Choose a registered adapter for the selected provider and an account supporting the required channels. -Set `EPP_PROVIDER_NAME` to its actual manifest id (`` is only a placeholder), and configure -its matching `EPP_PROVIDER_ENDPOINT` and required options. One provider is active per deployment; -request fields cannot change it. Purchasing or activating a subscription does not install an adapter. +Choose a registered adapter for the selected provider, channel, and endpoint region. Set +`EPP_PROVIDER_NAME` to its actual manifest id (`` is only a placeholder), and configure +the complete selected request URL in `EPP_PROVIDER_ENDPOINT`. One provider and channel route are +active per guided deployment; request fields cannot change them. Store credentials under the Key Vault secret names declared by the selected adapter's manifest, not in code or app settings. Grant the Function's managed identity *Key Vault Secrets User* access at the appropriate secret or vault scope. Confirm that the endpoint and credentials belong to the same account and environment. Individual API contracts stay in the adapters. -### Setup script compatibility +### Guided setup compatibility -The Preview 1 setup script creates the encryption-key secret, not the selected provider's API -credentials. Before live delivery, complete these steps: +The [EPP Step 2 setup](../setup/docs/README.md) uses one downloadable launcher, GitHub-hosted +language/provider catalogs, and one Bicep deployment approval. It downloads the selected language +ZIP, verifies its published checksum automatically, builds .NET for Linux or requests Azure remote +build for Python, and deploys the ready-to-run result. Application registration and policy activation +are manual. Authentication is provider-owned: Telesign uses API keys; Soprano uses OAuth +client-assertion exchange and creates the disclosed outbound federated identity credential. + +Before live delivery, complete these steps: 1. Set `KEY_VAULT_URL` to the vault containing the provider credentials. When it is the vault created by setup, use that vault's `vaultUri`; otherwise explicitly select the credential vault and grant the Function identity read access there. An encryption-key reference does not configure this client. -2. Store the API key under the selected manifest's `keyVaultSecretName` (`key_vault_secret_name` in - Python). If the manifest also declares `identityKeyVaultSecretName` - (`identity_key_vault_secret_name`), store the matching API/customer ID as a separate secret. - `EPP_PROVIDER_ACCOUNT_NAME` is a sender/account option, **not** that credential ID or the API key. - Keep secret values out of parameters, console transcripts and checked-in settings. -3. Give `EPP_PROVIDER_ENDPOINT` the **base URL expected by the adapter**. Bundled adapters append the - channel-specific API path. Do not pass an already complete send URL unless an adapter explicitly - expects it. Use the same account/environment for the endpoint and its credential pair. +2. For Telesign, store the API key and customer ID under the manifest's exact secret names. + `EPP_PROVIDER_ACCOUNT_NAME` is a sender/account option, **not** either credential. For Soprano, + complete provider consent/application-role onboarding for the existing multitenant application; + the Function stores no Soprano client secret. +3. Give `EPP_PROVIDER_ENDPOINT` the complete provider-approved URL for the selected channel and + Global/EU region. The Telesign and Soprano adapters use it exactly and do not append a route. 4. Supply any additional options read by the selected adapter. Registering a provider does not make every account option or channel automatically available. @@ -41,7 +45,11 @@ The script already writes the correct `EPP_` names; no variable-prefix translati | Setup value | Current application behavior | |---|---| | `EPP_PROVIDER_NAME` | Selects one registered adapter; no implicit default. | -| `EPP_PROVIDER_ENDPOINT` | Base URL, with the final send path built by the adapter. | +| `EPP_PROVIDER_ENDPOINT` | Complete selected provider request URL. | +| `EPP_PROVIDER_CHANNEL` | Restricts live delivery to the selected `sms` or `voice` route. | +| `EPP_PROVIDER_ENDPOINT_REGION` | Records the selected `global` or `eu` route. | +| `EPP_PROVIDER_AUTH_MODE` | `apiKey` for Telesign; `oauth` for Soprano. | +| `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE` | Soprano OAuth target tenant and scope. | | `EPP_PROVIDER_TIMEOUT_MS` | Default 1500 ms; positive decimal values are capped at 2500 ms. Zero/invalid values use the default, not an infinite timeout. | | `EPP_PROVIDER_RETRY_INTERVAL_MS` | Not consumed. Calls are not automatically retried; writing this setting does not enable retries. | | `EPP_PROVIDER_ACCOUNT_NAME` | Adapter-specific sender/account option, separate from credential secrets. | @@ -49,8 +57,8 @@ The script already writes the correct `EPP_` names; no variable-prefix translati | `EPP_ENCRYPTION_KEY_ID` | Advisory mismatch warning only; not overlapping-key selection. | | `EPP_EXPECTED_AUDIENCE`, `EPP_EXPECTED_ISSUER`, `EPP_EXPECTED_CLIENT_ID`, `EPP_TENANT_ID` | The script may write these, but this platform-authenticated application does not read them. The script's separate Easy Auth configuration enforces caller trust. | -**Do not use the script's `-NoEasyAuth` option with this application.** There is no application token -validator to take over. For the script's v1 registration, configure Easy Auth with the identifier URI +**Do not disable Easy Auth with this application.** There is no application token +validator to take over. For a v1 registration, configure Easy Auth with the identifier URI as audience, `https://sts.windows.net/{tenantId}/` as issuer, and the authorized SAS application in `allowedApplications`. Use the v2 audience/issuer only when the registration actually issues v2 tokens. No Entra application role check is performed. Azure RBAC grants to the Function's managed identity @@ -66,10 +74,10 @@ The script alone does not make this implementation conform to every Preview 1 re - The guide requires voice digits to be spoken separately. This implementation preserves the supplied message; verify the selected voice API's behavior rather than assuming unspaced digits are intelligible. -The pasted script also needs its advertised 100-byte UTF-8 endpoint-URL check before deployment. -A public-only certificate cannot supply the private key it later exports. Treat failed infrastructure -role assignments as failures unless the exact assignment is verified as already present. Verify these -script prerequisites separately; the application tests do not validate provisioning. +The guided deployment includes explicitly labelled dummy provider values for configuration testing. +These values are written into the actual Function App environment; they do not establish provider +connectivity. Replace the selected route with provider-approved settings, provision Telesign Key +Vault credentials or Soprano provider consent as applicable, and verify deployed security controls. ## 2. Provision encryption and deployment trust diff --git a/dotnet/README.md b/dotnet/README.md index 2a45212..57a7b9f 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -45,7 +45,8 @@ For local evaluation, start Azurite and replace the test-key placeholder in this } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and adapter-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; store provider credentials under the adapter manifest's Key Vault secret names, not in local settings. See the @@ -74,7 +75,7 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and +Live requests forward the rendered message unchanged using the configured provider's API key or OAuth token and await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). diff --git a/dotnet/Src/AppConfig.cs b/dotnet/Src/AppConfig.cs index 8c02454..2713864 100644 --- a/dotnet/Src/AppConfig.cs +++ b/dotnet/Src/AppConfig.cs @@ -6,6 +6,12 @@ public sealed class AppConfig public string? ExpectedKeyId { get; init; } public string? ProviderName { get; init; } public string? ProviderEndpoint { get; init; } + public string? ProviderChannel { get; init; } + public string? ProviderAuthMode { get; init; } + public string? ProviderTenantId { get; init; } + public string? ProviderScope { get; init; } + public string? OutboundClientId { get; init; } + public string? OutboundManagedIdentityClientId { get; init; } // Keep the raw value; DispatchEngine owns timeout normalization. public string? ProviderTimeoutMs { get; init; } @@ -15,6 +21,12 @@ public sealed class AppConfig ExpectedKeyId = env.Get("EPP_ENCRYPTION_KEY_ID"), ProviderName = env.Get("EPP_PROVIDER_NAME")?.Trim().ToLowerInvariant(), ProviderEndpoint = env.Get("EPP_PROVIDER_ENDPOINT"), + ProviderChannel = env.Get("EPP_PROVIDER_CHANNEL")?.Trim().ToLowerInvariant(), + ProviderAuthMode = env.Get("EPP_PROVIDER_AUTH_MODE")?.Trim(), + ProviderTenantId = env.Get("EPP_PROVIDER_TENANT_ID")?.Trim(), + ProviderScope = env.Get("EPP_PROVIDER_SCOPE")?.Trim(), + OutboundClientId = env.Get("EPP_OUTBOUND_CLIENT_ID")?.Trim(), + OutboundManagedIdentityClientId = env.Get("EPP_OUTBOUND_MI_CLIENT_ID")?.Trim(), ProviderTimeoutMs = env.Get("EPP_PROVIDER_TIMEOUT_MS"), }; } \ No newline at end of file diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index 1163547..deff095 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -1,3 +1,5 @@ +using Azure.Core; +using Azure.Identity; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -207,6 +209,9 @@ public sealed class DispatchEngine private readonly ISecretResolver _secrets; private readonly IHttpClientFactory _httpFactory; private readonly IEnv _env; + private readonly object _oauthLock = new(); + private TokenCredential? _oauthCredential; + private string? _oauthCredentialConfig; public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) { @@ -230,16 +235,23 @@ public async Task DispatchAsync(DispatchRequest dispatch, string if (!OutcomeMapper.DefaultChannels.Contains(channel)) return new DispatchResult(400, new { status = "error", provider = providerId, reason = "unsupported channel", requestId }); - if (manifest.Auth.Mode != "apiKey") - return new DispatchResult(502, FailBody(providerId, channel, "unsupported provider auth mode", dispatch, requestId)); + if (!string.IsNullOrEmpty(config.ProviderChannel) && config.ProviderChannel != channel) + return new DispatchResult(400, new { status = "error", provider = providerId, reason = "channel not configured", requestId }); + if (!string.IsNullOrEmpty(config.ProviderAuthMode) && config.ProviderAuthMode != manifest.Auth.Mode) + return new DispatchResult(502, FailBody(providerId, channel, "provider authentication mismatch", dispatch, requestId)); ProviderCredential credential; - try { credential = await ResolveCredentialAsync(manifest.Auth); } + try { credential = await ResolveCredentialAsync(manifest.Auth, config); } catch { return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); } - var identityRequired = !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); - var credentialUnavailable = string.IsNullOrEmpty(credential.Secret) - || (identityRequired && string.IsNullOrEmpty(credential.Identity)); + var identityRequired = credential.Mode == "apiKey" && !string.IsNullOrEmpty(manifest.Auth.IdentityKeyVaultSecretName); + var credentialUnavailable = credential.Mode switch + { + "apiKey" => string.IsNullOrEmpty(credential.Secret) + || (identityRequired && string.IsNullOrEmpty(credential.Identity)), + "oauth" => string.IsNullOrEmpty(credential.AccessToken), + _ => true, + }; if (credentialUnavailable) return new DispatchResult(502, FailBody(providerId, channel, "provider credential unavailable", dispatch, requestId)); @@ -284,11 +296,44 @@ public async Task DispatchAsync(DispatchRequest dispatch, string } } - private async Task ResolveCredentialAsync(AuthConfig auth) + private async Task ResolveCredentialAsync(AuthConfig auth, AppConfig config) { - var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); - var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); - return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + if (auth.Mode == "apiKey") + { + var secret = await _secrets.ResolveAsync(auth.KeyVaultSecretName); + var identity = string.IsNullOrEmpty(auth.IdentityKeyVaultSecretName) ? string.Empty : await _secrets.ResolveAsync(auth.IdentityKeyVaultSecretName); + return new ProviderCredential("apiKey", Secret: secret, Identity: identity); + } + if (auth.Mode != "oauth" || string.IsNullOrEmpty(config.ProviderTenantId) + || string.IsNullOrEmpty(config.ProviderScope) || string.IsNullOrEmpty(config.OutboundClientId) + || string.IsNullOrEmpty(config.OutboundManagedIdentityClientId)) + throw new InvalidOperationException("unsupported or incomplete provider authentication"); + + var credentialConfig = string.Join("|", config.ProviderTenantId, config.OutboundClientId, config.OutboundManagedIdentityClientId); + TokenCredential providerCredential; + lock (_oauthLock) + { + if (_oauthCredential is null || _oauthCredentialConfig != credentialConfig) + { + var managedIdentity = new ManagedIdentityCredential(config.OutboundManagedIdentityClientId); + _oauthCredential = new ClientAssertionCredential( + config.ProviderTenantId, + config.OutboundClientId, + async cancellationToken => + { + var assertion = await managedIdentity.GetTokenAsync( + new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }), + cancellationToken); + return assertion.Token; + }); + _oauthCredentialConfig = credentialConfig; + } + providerCredential = _oauthCredential; + } + var token = await providerCredential.GetTokenAsync( + new TokenRequestContext(new[] { config.ProviderScope }), + CancellationToken.None); + return new ProviderCredential("oauth", AccessToken: token.Token); } internal static int NormalizeProviderTimeoutMs(string? value) diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index e8f14c6..be14ad6 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -26,7 +26,7 @@ public sealed record DispatchRequest( string? CorrelationId, string? Locale); -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null); +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? AccessToken = null); public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); diff --git a/dotnet/Src/Providers/SopranoProvider.cs b/dotnet/Src/Providers/SopranoProvider.cs index 7ffb5df..c9ebf56 100644 --- a/dotnet/Src/Providers/SopranoProvider.cs +++ b/dotnet/Src/Providers/SopranoProvider.cs @@ -6,7 +6,7 @@ public sealed class SopranoProvider : IProviderAdapter { public ProviderManifest Manifest { get; } = new( Id: "soprano", - Auth: new AuthConfig("apiKey", KeyVaultSecretName: "soprano-api-key", IdentityKeyVaultSecretName: "soprano-api-id"), + Auth: new AuthConfig("oauth"), ResponseMapping: new Dictionary { ["ENROUTE"] = Outcome.Continue, @@ -28,8 +28,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc { ["Content-Type"] = "application/json", ["Accept"] = "application/json", - ["X-MEMS-API-ID"] = credential.Identity ?? string.Empty, - ["X-MEMS-API-Key"] = credential.Secret ?? string.Empty, + ["Authorization"] = "Bearer " + credential.AccessToken, }; var body = new { @@ -40,7 +39,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc shutterMode = false, }; - return new ProviderHttpRequest($"{endpoint.TrimEnd('/')}/messages/omnimsg", "POST", headers, JsonSerializer.Serialize(body)); + return new ProviderHttpRequest(endpoint, "POST", headers, JsonSerializer.Serialize(body)); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) diff --git a/dotnet/Src/Providers/TelesignProvider.cs b/dotnet/Src/Providers/TelesignProvider.cs index 79978ad..c1f366b 100644 --- a/dotnet/Src/Providers/TelesignProvider.cs +++ b/dotnet/Src/Providers/TelesignProvider.cs @@ -28,10 +28,8 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc var externalId = dispatch.CorrelationId ?? dispatch.MessageId; var form = new Dictionary(); - string path; if (channel == "voice") { - path = "/v1/voice"; form["phone_number"] = dispatch.Destination; form["message"] = dispatch.Message ?? string.Empty; form["message_type"] = "OTP"; @@ -40,7 +38,6 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc } else { - path = "/v1/messaging"; form["phone_number"] = dispatch.Destination; form["message"] = dispatch.Message ?? string.Empty; form["sender_id"] = env.Get("EPP_PROVIDER_ACCOUNT_NAME") ?? string.Empty; @@ -56,7 +53,7 @@ public ProviderHttpRequest BuildRequest(string channel, string endpoint, Dispatc ["Accept"] = "application/json", }; var encoded = string.Join("&", form.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); - return new ProviderHttpRequest($"{endpoint}{path}", "POST", headers, encoded); + return new ProviderHttpRequest(endpoint, "POST", headers, encoded); } public ParsedResponse ParseResponse(int httpStatus, bool ok, JsonElement json) diff --git a/dotnet/tests/ContractTests.cs b/dotnet/tests/ContractTests.cs index e78a06b..cea4a2c 100644 --- a/dotnet/tests/ContractTests.cs +++ b/dotnet/tests/ContractTests.cs @@ -13,15 +13,14 @@ private static DispatchRequest Request(string channel = "sms") => [Theory] [InlineData("sms")] [InlineData("voice")] - public void SopranoUsesExactOmnimsgContract(string channel) + public void SopranoUsesSelectedEndpointAndOAuth(string channel) { - var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/cgpapi///", Request(channel), - new ProviderCredential("apiKey", "test-key", "test-id"), new TestEnv()); - Assert.Equal("https://provider.example/cgpapi/messages/omnimsg", request.Url); + var request = new SopranoProvider().BuildRequest(channel, "https://provider.example/oauth/messages", Request(channel), + new ProviderCredential("oauth", AccessToken: "provider-token"), new TestEnv()); + Assert.Equal("https://provider.example/oauth/messages", request.Url); Assert.Equal("POST", request.Method); - Assert.Equal(4, request.Headers.Count); - Assert.Equal("test-id", request.Headers["X-MEMS-API-ID"]); - Assert.Equal("test-key", request.Headers["X-MEMS-API-Key"]); + Assert.Equal(3, request.Headers.Count); + Assert.Equal("Bearer provider-token", request.Headers["Authorization"]); Assert.Equal("application/json", request.Headers["Accept"]); Assert.Equal("application/json", request.Headers["Content-Type"]); var expected = new @@ -67,10 +66,10 @@ public void OtherProvidersKeepTheirStaticAuthenticationAndProtocols() using var smsJson = JsonDocument.Parse(sms.Body); Assert.Equal(Request().Message, smsJson.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); - var form = new TelesignProvider().BuildRequest("sms", "https://provider.example", Request(), credential, env); + var form = new TelesignProvider().BuildRequest("sms", "https://provider.example/epp/sms", Request(), credential, env); Assert.Equal("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("test-id:test-key")), form.Headers["Authorization"]); Assert.Equal("application/x-www-form-urlencoded", form.Headers["Content-Type"]); - Assert.EndsWith("/v1/messaging", form.Url); + Assert.Equal("https://provider.example/epp/sms", form.Url); Assert.Contains("message=" + Uri.EscapeDataString(Request().Message!), form.Body); var call = new SinchProvider().BuildRequest("voice", "https://provider.example", Request("voice"), credential, env); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index 6324883..dca76cc 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -23,7 +23,7 @@ public class EngineTests public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() { using var rig = new HandlerRig(); - Assert.Equal("soprano", AppConfig.Read(rig.Env).ProviderName); + Assert.Equal("infobip", AppConfig.Read(rig.Env).ProviderName); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); rig.Http.Respond = cancellation => @@ -31,7 +31,7 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() entered.TrySetResult(); return release.Task.WaitAsync(cancellation); }; - var pending = rig.Invoke(channel: "voice"); + var pending = rig.Invoke(channel: "sms"); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -39,11 +39,11 @@ public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() } finally { - release.TrySetResult(Json(201, "{\"status\":\"ENROUTE\"}")); + release.TrySetResult(Json(200, "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}")); } AssertAccepted(await pending); using var body = JsonDocument.Parse(rig.Http.Body!); - Assert.Equal(Message, body.RootElement.GetProperty("text").GetString()); + Assert.Equal(Message, body.RootElement.GetProperty("messages")[0].GetProperty("content").GetProperty("text").GetString()); Assert.Equal(1, rig.Http.Calls); var log = Assert.Single(rig.Log.Messages); var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Correlation)))[..16].ToLowerInvariant(); @@ -79,6 +79,8 @@ public async Task ResponseBodyTimeoutCancelsWithoutRetryOrSuccessNonce() public async Task MissingIdentityOrKeyFailsClosedBeforeHttp() { using var rig = new HandlerRig(); + rig.Env["EPP_PROVIDER_NAME"] = "telesign"; + rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/epp/sms"; rig.Secrets.Identity = ""; AssertFailure(rig, await rig.Invoke(), 502); rig.Secrets.Identity = "private-api-id"; @@ -240,8 +242,8 @@ public HandlerRig() { Env = new TestEnv { - ["EPP_PROVIDER_NAME"] = "soprano", - ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/cgpapi", + ["EPP_PROVIDER_NAME"] = "infobip", + ["EPP_PROVIDER_ENDPOINT"] = "https://provider.example", ["EPP_PROVIDER_TIMEOUT_MS"] = "2500", }; var registry = new ProviderRegistry(new IProviderAdapter[] @@ -285,7 +287,7 @@ private sealed class TestSecrets : ISecretResolver public Task ResolveAsync(string? name) { Calls++; - return Task.FromResult(name == "soprano-api-id" ? Identity : Secret); + return Task.FromResult(name is "soprano-api-id" or "telesign-customer-id" ? Identity : Secret); } } @@ -294,7 +296,7 @@ private sealed class TestHttp : HttpMessageHandler, IHttpClientFactory public int Calls { get; private set; } public string? Body { get; private set; } public Func> Respond { get; set; } = - _ => Task.FromResult(Json(201, "{\"status\":\"ACCEPTED\"}")); + _ => Task.FromResult(Json(200, "{\"messages\":[{\"messageId\":\"id\",\"status\":{\"groupName\":\"PENDING\"}}]}")); public HttpClient CreateClient(string name) => new(this, disposeHandler: false); protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { diff --git a/javascript/README.md b/javascript/README.md index 293e006..964b787 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -46,7 +46,8 @@ the test-key placeholder in this minimal setup: } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Optional `EPP_PROVIDER_TIMEOUT_MS` is a string such as `"1500"`. Replace placeholders; do not put API keys in this file. See the [complete variable table](../README.md#configure-environment-variables). @@ -64,9 +65,8 @@ configure the current shared engine. Use `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDP that are actually read, such as a service-plan ID or voice selection. Private integration helpers may load settings from another location or use test credential variables, but the Function itself does not. -For the omnimsg adapter, the configured base ends in `/cgpapi`; the adapter appends `/messages/omnimsg` -for SMS and voice. QA4 is the test environment; select the provider-approved production base separately. -The base URL is not hard-coded and changing local settings does not change an already deployed app. +The Soprano adapter uses the configured complete endpoint and an OAuth bearer token. The Telesign +adapter uses the configured complete endpoint and API-key credentials from Key Vault. For Azure, set these application variables on the Function App/slot's **Environment variables → App settings** page and use a Key Vault reference for the private PEM. The provider-secret resolver uses @@ -87,7 +87,7 @@ works for every provider without provider configuration, provider Key Vault read platform authentication on Azure and handler decryption still run. No diagnostic environment flag is needed. See the [evaluation contract](../docs/CONTRACT.md#evaluation-generic-shutter) for authentication/key prerequisites. -Live requests use the configured provider's API key and await acceptance before returning the nonce. +Live requests use the configured provider's API key or OAuth token and await acceptance before returning the nonce. Acceptance is not handset delivery; failures omit the nonce, and timeouts must not trigger blind retries. The shared contract defines validation, HTTP outcomes and privacy-safe logging. diff --git a/javascript/src/functions/config.js b/javascript/src/functions/config.js index 6b216a5..90c139e 100644 --- a/javascript/src/functions/config.js +++ b/javascript/src/functions/config.js @@ -12,6 +12,12 @@ class AppConfig { this.expectedKeyId = env.EPP_ENCRYPTION_KEY_ID || ''; this.providerName = (env.EPP_PROVIDER_NAME || '').trim().toLowerCase(); this.providerEndpoint = env.EPP_PROVIDER_ENDPOINT || ''; + this.providerChannel = (env.EPP_PROVIDER_CHANNEL || '').trim().toLowerCase(); + this.providerAuthMode = (env.EPP_PROVIDER_AUTH_MODE || '').trim(); + this.providerTenantId = (env.EPP_PROVIDER_TENANT_ID || '').trim(); + this.providerScope = (env.EPP_PROVIDER_SCOPE || '').trim(); + this.outboundClientId = (env.EPP_OUTBOUND_CLIENT_ID || '').trim(); + this.outboundManagedIdentityClientId = (env.EPP_OUTBOUND_MI_CLIENT_ID || '').trim(); this.providerTimeoutMs = env.EPP_PROVIDER_TIMEOUT_MS || ''; this.keyVaultUrl = (env.KEY_VAULT_URL || '').trim(); this.managedIdentityClientId = (env.AZURE_CLIENT_ID || '').trim(); diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index e33383e..0c6682b 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -6,7 +6,7 @@ const crypto = require('crypto'); const { compactDecrypt } = require('jose'); -const { ManagedIdentityCredential } = require('@azure/identity'); +const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { readConfig } = require('./config'); const { DeliveryContext } = require('./models'); @@ -162,6 +162,8 @@ function getProvider(providerId) { let keyVaultSecretClient = null; let keyVaultClientConfig; const secretCache = new Map(); +let oauthCredential = null; +let oauthCredentialConfig; function getKeyVaultSecretClient(config) { const cacheKey = JSON.stringify([config.keyVaultUrl, config.managedIdentityClientId]); @@ -196,15 +198,38 @@ async function resolveSecretValue(keyVaultSecretName, config) { async function resolveProviderCredential(authConfiguration = {}, config) { const { mode = 'apiKey' } = authConfiguration; - if (mode !== 'apiKey') throw new Error('unsupported provider authentication'); - - const [secret, identity] = await Promise.all([ - resolveSecretValue(authConfiguration.keyVaultSecretName, config), - authConfiguration.identityKeyVaultSecretName - ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) - : Promise.resolve(''), + if (mode === 'apiKey') { + const [secret, identity] = await Promise.all([ + resolveSecretValue(authConfiguration.keyVaultSecretName, config), + authConfiguration.identityKeyVaultSecretName + ? resolveSecretValue(authConfiguration.identityKeyVaultSecretName, config) + : Promise.resolve(''), + ]); + return { mode: 'apiKey', secret, identity }; + } + if (mode !== 'oauth' || !config.providerTenantId || !config.providerScope + || !config.outboundClientId || !config.outboundManagedIdentityClientId) { + throw new Error('unsupported or incomplete provider authentication'); + } + const credentialConfig = JSON.stringify([ + config.providerTenantId, config.outboundClientId, config.outboundManagedIdentityClientId, ]); - return { mode: 'apiKey', secret, identity }; + if (!oauthCredential || oauthCredentialConfig !== credentialConfig) { + const assertionIdentity = new ManagedIdentityCredential(config.outboundManagedIdentityClientId); + oauthCredential = new ClientAssertionCredential( + config.providerTenantId, + config.outboundClientId, + async () => { + const assertion = await assertionIdentity.getToken('api://AzureADTokenExchange/.default'); + if (!assertion?.token) throw new Error('managed identity assertion unavailable'); + return assertion.token; + }, + ); + oauthCredentialConfig = credentialConfig; + } + const accessToken = await oauthCredential.getToken(config.providerScope); + if (!accessToken?.token) throw new Error('provider OAuth token unavailable'); + return { mode: 'oauth', accessToken: accessToken.token }; } // Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. @@ -305,6 +330,12 @@ async function sendViaProvider(providerEntry, dispatch, options) { if (!['sms', 'voice'].includes(channel)) { return { httpStatus: 400, body: { status: 'error', reason: 'unsupported channel', requestId } }; } + if (config.providerChannel && config.providerChannel !== channel) { + return { httpStatus: 400, body: { status: 'error', provider: providerId, reason: 'channel not configured', requestId } }; + } + if (config.providerAuthMode && config.providerAuthMode !== manifest.auth?.mode) { + return { httpStatus: 502, body: failBody(providerId, channel, 'provider authentication mismatch', dispatch, requestId) }; + } const endpointBaseUrl = config.providerEndpoint; if (!isValidProviderUrl(endpointBaseUrl)) { @@ -317,9 +348,10 @@ async function sendViaProvider(providerEntry, dispatch, options) { } catch { // Configuration and secret lookup failures share a generic failure response. } - const identityRequired = !!manifest.auth?.identityKeyVaultSecretName; - const credentialUnavailable = !credential || !credential.secret - || (identityRequired && !credential.identity); + const identityRequired = credential?.mode === 'apiKey' && !!manifest.auth?.identityKeyVaultSecretName; + const credentialUnavailable = !credential + || (credential.mode === 'apiKey' && (!credential.secret || (identityRequired && !credential.identity))) + || (credential.mode === 'oauth' && !credential.accessToken); if (credentialUnavailable) { return { httpStatus: 502, body: failBody(providerId, channel, 'provider credential unavailable', dispatch, requestId) }; } @@ -398,4 +430,5 @@ module.exports = { outcomeToHttpStatus, parseProviderTimeout, isValidProviderUrl, + resolveProviderCredential, }; diff --git a/javascript/src/functions/providers/soprano.js b/javascript/src/functions/providers/soprano.js index de86805..9faed9b 100644 --- a/javascript/src/functions/providers/soprano.js +++ b/javascript/src/functions/providers/soprano.js @@ -8,11 +8,7 @@ const { ParsedResponse } = require('../models'); const manifest = { id: 'soprano', - auth: { - mode: 'apiKey', - keyVaultSecretName: 'soprano-api-key', - identityKeyVaultSecretName: 'soprano-api-id', - }, + auth: { mode: 'oauth' }, responseMapping: { ENROUTE: 'Continue', ACCEPTED: 'Continue', @@ -29,13 +25,10 @@ const manifest = { }; function buildRequest({ channel, endpoint, dispatch, credential }) { - let base = endpoint; - while (base.endsWith('/')) base = base.slice(0, -1); const headers = { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': credential.identity, - 'X-MEMS-API-Key': credential.secret, + Authorization: `Bearer ${credential.accessToken}`, }; let destination = String(dispatch.destination || ''); while (destination.startsWith('+')) destination = destination.slice(1); @@ -46,7 +39,7 @@ function buildRequest({ channel, endpoint, dispatch, credential }) { correlationId: dispatch.correlationId || dispatch.messageId, shutterMode: false, }; - return { url: `${base}/messages/omnimsg`, method: 'POST', headers, body: JSON.stringify(body) }; + return { url: endpoint, method: 'POST', headers, body: JSON.stringify(body) }; } function parseResponse({ httpStatus, ok, json }) { diff --git a/javascript/src/functions/providers/telesign.js b/javascript/src/functions/providers/telesign.js index 5ba03a1..b37d081 100644 --- a/javascript/src/functions/providers/telesign.js +++ b/javascript/src/functions/providers/telesign.js @@ -33,10 +33,8 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { const contentType = 'application/x-www-form-urlencoded'; const authorization = `Basic ${Buffer.from(`${credential.identity}:${credential.secret}`).toString('base64')}`; - let path; let params; if (channel === 'voice') { - path = '/v1/voice'; params = new URLSearchParams({ phone_number: dispatch.destination, message: dispatch.message, @@ -45,7 +43,6 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { external_id: dispatch.correlationId || dispatch.messageId, }); } else { - path = '/v1/messaging'; params = new URLSearchParams({ phone_number: dispatch.destination, message: dispatch.message, @@ -57,7 +54,7 @@ function buildRequest({ channel, endpoint, dispatch, credential, env }) { } return { - url: `${base}${path}`, + url: base, method: 'POST', headers: { Authorization: authorization, diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index 28d7f51..d7fa8fc 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -2,6 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); +const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const { AppConfig, readConfig } = require('../src/functions/config'); const { DeliveryContext, ParsedResponse } = require('../src/functions/models'); @@ -9,7 +10,7 @@ const fixtures = require('../../tests/fixtures/contract.json'); const { inspect } = require('node:util'); const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, - parseEnvelope, parseProviderTimeout, isValidProviderUrl, + parseEnvelope, parseProviderTimeout, isValidProviderUrl, resolveProviderCredential, } = require('../src/functions/dispatch'); const dispatch = { destination: '+15551234567', message: ' Your code is 918273.\n', channel: 'sms', messageId: 'message-id', correlationId: 'correlation-id' }; @@ -80,12 +81,17 @@ test('provider URLs and timeouts retain representative safety boundaries', () => assert.equal(parseProviderTimeout('9999'), 2500); }); -test('omnimsg preserves its API-key request and normalizes acceptance', () => { - const request = getProvider('soprano').adapter.buildRequest({ ...input, env: undefined, endpoint: `${input.endpoint}/cgpapi///` }); - assert.equal(request.url, 'https://provider.example/cgpapi/messages/omnimsg'); +test('Soprano uses the selected endpoint and OAuth bearer token', () => { + const request = getProvider('soprano').adapter.buildRequest({ + ...input, + credential: { mode: 'oauth', accessToken: 'provider-token' }, + env: undefined, + endpoint: `${input.endpoint}/oauth/messages`, + }); + assert.equal(request.url, 'https://provider.example/oauth/messages'); assert.equal(request.method, 'POST'); assert.deepEqual(request.headers, { 'Content-Type': 'application/json', Accept: 'application/json', - 'X-MEMS-API-ID': 'id', 'X-MEMS-API-Key': 'key' }); + Authorization: 'Bearer provider-token' }); assert.deepEqual(JSON.parse(request.body), { text: dispatch.message, destination: '15551234567', messageTypes: ['sms'], correlationId: 'correlation-id', shutterMode: false }); const response = getProvider('soprano').adapter.parseResponse({ httpStatus: 201, ok: true, @@ -108,8 +114,8 @@ test('App-auth SMS preserves its request and normalizes acceptance', () => { }); test('Basic-auth SMS preserves its form request and normalizes acceptance', () => { - const request = getProvider('telesign').adapter.buildRequest(input); - assert.equal(request.url, 'https://provider.example/v1/messaging'); + const request = getProvider('telesign').adapter.buildRequest({ ...input, endpoint: 'https://provider.example/epp/sms' }); + assert.equal(request.url, 'https://provider.example/epp/sms'); assert.equal(request.headers.Authorization, `Basic ${Buffer.from('id:key').toString('base64')}`); assert.equal(request.headers['Content-Type'], 'application/x-www-form-urlencoded'); assert.equal(new URLSearchParams(request.body).get('message'), dispatch.message); @@ -150,11 +156,11 @@ test('response parsing and HTTP mapping fail closed, including malformed status/ } }); -test('missing key/identity and an unsafe final voice URL make zero HTTP calls', async (t) => { +test('missing API-key or OAuth settings and an unsafe final voice URL make zero HTTP calls', async (t) => { const settings = { KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_ENDPOINT: input.endpoint, SINCH_VOICE_ENDPOINT: 'http://unsafe.example' }; const getSecret = t.mock.method(SecretClient.prototype, 'getSecret', async (name) => ({ - value: ['soprano-api-id', 'telesign-api-key'].includes(name) ? '' : 'fixture-key', + value: name === 'telesign-api-key' ? '' : 'fixture-key', })); const fetchMock = t.mock.method(global, 'fetch', () => assert.fail('unexpected HTTP')); for (const [providerName, channel, reason] of [ @@ -178,3 +184,17 @@ test('missing key/identity and an unsafe final voice URL make zero HTTP calls', assert.equal(new Set(getSecret.mock.calls.map((call) => call.this)).size, 3); assert.equal(fetchMock.mock.callCount(), 0); }); + +test('Soprano OAuth requests the selected provider scope', async (t) => { + t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ token: 'assertion-token' })); + const providerToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => ({ token: 'provider-token' })); + const config = readConfig({ + EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', + EPP_PROVIDER_SCOPE: 'api://provider/.default', + EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', + EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', + }); + const credential = await resolveProviderCredential({ mode: 'oauth' }, config); + assert.deepEqual(credential, { mode: 'oauth', accessToken: 'provider-token' }); + assert.equal(providerToken.mock.calls[0].arguments[0], 'api://provider/.default'); +}); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 0d79552..536f2b3 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -36,11 +36,12 @@ beforeEach(() => { for (const key of envKeys) delete process.env[key]; Object.assign(process.env, { EPP_LOG_PLAINTEXT: 'true', EPP_DECRYPTION_KEY_PEM: privateKey.export({ type: 'pkcs8', format: 'pem' }), - KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'soprano', - EPP_PROVIDER_ENDPOINT: 'https://provider.example/cgpapi/' }); + KEY_VAULT_URL: 'https://unit-test.vault.azure.net', EPP_PROVIDER_NAME: 'telesign', + EPP_PROVIDER_ENDPOINT: 'https://provider.example/epp/send', + EPP_PROVIDER_AUTH_MODE: 'apiKey' }); getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); - fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, - text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); + fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 200, + text: async () => JSON.stringify({ reference_id: 'PRIVATE-ID', status: { code: 290, description: 'PRIVATE-STATUS' } }) })); }); afterEach(() => { mock.restoreAll(); @@ -165,8 +166,10 @@ test('SMS/voice preserve content and correlation without reflecting headers or l assert.equal(result.status, 200); assert.deepEqual(result.jsonBody, { nonce: delivery.nonce, correlationId, providerStatus: 'accepted' }); const init = fetchMock.mock.calls.at(-1).arguments[1]; - const sent = JSON.parse(init.body); - assert.deepEqual([sent.text, sent.messageTypes, sent.correlationId], [delivery.message, [name], correlationId]); + const sent = new URLSearchParams(init.body); + assert.deepEqual([sent.get('message'), sent.get('message_type'), sent.get('external_id')], + [delivery.message, 'OTP', correlationId]); + assert.equal(fetchMock.mock.calls.at(-1).arguments[0], 'https://provider.example/epp/send'); assert.equal(init.redirect, 'manual'); assert.equal(logs.length, 1); assert.deepEqual(Object.keys(logs[0]).sort(), ['correlationId', 'elapsedMs', 'evaluation', 'httpStatus', 'requestId']); diff --git a/python/README.md b/python/README.md index de7afdb..1c44fd4 100644 --- a/python/README.md +++ b/python/README.md @@ -45,7 +45,8 @@ For local evaluation, start Azurite and replace the test-key placeholder in this } ``` -For live delivery, add `EPP_PROVIDER_NAME`, `EPP_PROVIDER_ENDPOINT` and `KEY_VAULT_URL` to `Values`. +For live delivery, add `EPP_PROVIDER_NAME`, the complete selected `EPP_PROVIDER_ENDPOINT`, and the +matching provider authentication settings to `Values`. Add `EPP_PROVIDER_ACCOUNT_NAME` and any adapter-specific options only when required. Keep values as strings, including optional `EPP_PROVIDER_TIMEOUT_MS: "1500"`. Replace placeholders; provider API keys belong in the manifest-named Key Vault secrets, not this file. See the @@ -74,7 +75,7 @@ authenticate SAS: anyone with the public key can encrypt a request, and a fixed Use incoming `mode: 2` or `mode: "evaluation"` as the generic shutter for every provider: platform authentication on Azure, handler validation and decryption run, but provider lookup, provider Key Vault reads and provider HTTP do not. No provider configuration or diagnostic environment flag is required. -Live requests forward the rendered message unchanged using the configured provider's API key and +Live requests forward the rendered message unchanged using the configured provider's API key or OAuth token and await acceptance before returning the nonce; failures omit it. Acceptance is not handset delivery. Platform/key prerequisites and HTTP outcomes are defined in the [contract](../docs/CONTRACT.md#evaluation-generic-shutter). diff --git a/python/src/config.py b/python/src/config.py index c1fd9f6..3627961 100644 --- a/python/src/config.py +++ b/python/src/config.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from collections.abc import Mapping from dataclasses import dataclass @@ -9,6 +11,12 @@ class AppConfig: expected_key_id: str | None provider_name: str provider_endpoint: str | None + provider_channel: str + provider_auth_mode: str + provider_tenant_id: str + provider_scope: str + outbound_client_id: str + outbound_managed_identity_client_id: str provider_timeout_ms: str | None env: Mapping[str, str] @@ -20,6 +28,12 @@ def read_config(env: Mapping[str, str] | None = None) -> AppConfig: expected_key_id=env.get("EPP_ENCRYPTION_KEY_ID"), provider_name=(env.get("EPP_PROVIDER_NAME") or "").strip().lower(), provider_endpoint=env.get("EPP_PROVIDER_ENDPOINT"), + provider_channel=(env.get("EPP_PROVIDER_CHANNEL") or "").strip().lower(), + provider_auth_mode=(env.get("EPP_PROVIDER_AUTH_MODE") or "").strip(), + provider_tenant_id=(env.get("EPP_PROVIDER_TENANT_ID") or "").strip(), + provider_scope=(env.get("EPP_PROVIDER_SCOPE") or "").strip(), + outbound_client_id=(env.get("EPP_OUTBOUND_CLIENT_ID") or "").strip(), + outbound_managed_identity_client_id=(env.get("EPP_OUTBOUND_MI_CLIENT_ID") or "").strip(), provider_timeout_ms=env.get("EPP_PROVIDER_TIMEOUT_MS"), env=env, # Preserve raw adapter settings and the injected environment. ) \ No newline at end of file diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 47c7ccd..f1a58eb 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import base64 import json import os from urllib.parse import urlsplit import requests +from azure.identity import ClientAssertionCredential, ManagedIdentityCredential from jwcrypto import jwe as jwe_module from jwcrypto import jwk from urllib3.exceptions import ReadTimeoutError @@ -240,6 +243,8 @@ def __init__(self, registry, secrets, env=None): self.registry = registry self.secrets = secrets self.env = env if env is not None else os.environ + self._oauth_credential = None + self._oauth_credential_config = None def dispatch(self, dispatch, request_id): config = read_config(self.env) @@ -256,15 +261,21 @@ def dispatch(self, dispatch, request_id): if channel not in DEFAULT_CHANNELS: return 400, {"status": "error", "provider": provider_id, "reason": "unsupported channel", "requestId": request_id} + if config.provider_channel and config.provider_channel != channel: + return 400, {"status": "error", "provider": provider_id, "reason": "channel not configured", "requestId": request_id} auth = manifest["auth"] - if auth.get("mode") != "apiKey": - return 502, self._fail_body(provider_id, channel, "unsupported provider auth mode", dispatch, request_id) + if config.provider_auth_mode and config.provider_auth_mode != auth.get("mode"): + return 502, self._fail_body(provider_id, channel, "provider authentication mismatch", dispatch, request_id) try: - credential = self._resolve_credential(auth) + credential = self._resolve_credential(auth, config) except Exception: return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) - if not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity")): + credential_unavailable = ( + credential.get("mode") == "apiKey" + and (not credential.get("secret") or (auth.get("identity_key_vault_secret_name") and not credential.get("identity"))) + ) or (credential.get("mode") == "oauth" and not credential.get("access_token")) + if credential_unavailable: return 502, self._fail_body(provider_id, channel, "provider credential unavailable", dispatch, request_id) endpoint = config.provider_endpoint @@ -331,10 +342,40 @@ def dispatch(self, dispatch, request_id): except Exception: pass - def _resolve_credential(self, auth): - secret = self.secrets.resolve(auth.get("key_vault_secret_name")) - identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" - return {"mode": "apiKey", "secret": secret, "identity": identity} + def _resolve_credential(self, auth, config): + if auth.get("mode") == "apiKey": + secret = self.secrets.resolve(auth.get("key_vault_secret_name")) + identity = self.secrets.resolve(auth.get("identity_key_vault_secret_name")) if auth.get("identity_key_vault_secret_name") else "" + return {"mode": "apiKey", "secret": secret, "identity": identity} + if auth.get("mode") != "oauth" or not all(( + config.provider_tenant_id, config.provider_scope, + config.outbound_client_id, config.outbound_managed_identity_client_id, + )): + raise ValueError("unsupported or incomplete provider authentication") + credential_config = ( + config.provider_tenant_id, + config.outbound_client_id, + config.outbound_managed_identity_client_id, + ) + if self._oauth_credential is None or self._oauth_credential_config != credential_config: + assertion_identity = ManagedIdentityCredential(client_id=config.outbound_managed_identity_client_id) + + def get_assertion(): + token = assertion_identity.get_token("api://AzureADTokenExchange/.default") + if not token or not token.token: + raise ValueError("managed identity assertion unavailable") + return token.token + + self._oauth_credential = ClientAssertionCredential( + tenant_id=config.provider_tenant_id, + client_id=config.outbound_client_id, + func=get_assertion, + ) + self._oauth_credential_config = credential_config + token = self._oauth_credential.get_token(config.provider_scope) + if not token or not token.token: + raise ValueError("provider OAuth token unavailable") + return {"mode": "oauth", "access_token": token.token} def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/src/models.py b/python/src/models.py index 8564af4..13cdec8 100644 --- a/python/src/models.py +++ b/python/src/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass diff --git a/python/src/providers/soprano.py b/python/src/providers/soprano.py index 93e088b..b06a749 100644 --- a/python/src/providers/soprano.py +++ b/python/src/providers/soprano.py @@ -6,11 +6,7 @@ class SopranoProvider: manifest = { "id": "soprano", - "auth": { - "mode": "apiKey", - "key_vault_secret_name": "soprano-api-key", - "identity_key_vault_secret_name": "soprano-api-id", - }, + "auth": {"mode": "oauth"}, "response_mapping": { "ENROUTE": "Continue", "ACCEPTED": "Continue", "SUBMITTED": "Continue", "SENT": "Continue", "DELIVERED": "Continue", "QUEUED": "Continue", @@ -21,8 +17,7 @@ class SopranoProvider: def build_request(self, channel, endpoint, dispatch, credential, env): message_type = "voice" if channel == "voice" else "sms" headers = { - "X-MEMS-API-ID": credential.get("identity") or "", - "X-MEMS-API-Key": credential.get("secret") or "", + "Authorization": f"Bearer {credential.get('access_token') or ''}", "Content-Type": "application/json", "Accept": "application/json", } @@ -33,7 +28,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "correlationId": dispatch.correlation_id or dispatch.message_id, "shutterMode": False, } - return {"url": f"{endpoint.rstrip('/')}/messages/omnimsg", "method": "POST", "headers": headers, "body": json.dumps(body)} + return {"url": endpoint, "method": "POST", "headers": headers, "body": json.dumps(body)} def parse_response(self, http_status, ok, json_body): payload = json_body[0] if isinstance(json_body, list) and json_body else json_body diff --git a/python/src/providers/telesign.py b/python/src/providers/telesign.py index 58bd95a..6c255d8 100644 --- a/python/src/providers/telesign.py +++ b/python/src/providers/telesign.py @@ -25,7 +25,6 @@ def build_request(self, channel, endpoint, dispatch, credential, env): external_id = dispatch.correlation_id or dispatch.message_id if channel == "voice": - path = "/v1/voice" form = { "phone_number": dispatch.destination, "message": dispatch.message or "", @@ -34,7 +33,6 @@ def build_request(self, channel, endpoint, dispatch, credential, env): "external_id": external_id, } else: - path = "/v1/messaging" form = { "phone_number": dispatch.destination, "message": dispatch.message or "", @@ -45,7 +43,7 @@ def build_request(self, channel, endpoint, dispatch, credential, env): } headers = {"Authorization": authorization, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} - return {"url": f"{endpoint}{path}", "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} + return {"url": endpoint, "method": "POST", "headers": headers, "body": urllib.parse.urlencode(form)} def parse_response(self, http_status, ok, json_body): status = json_body.get("status") or {} if isinstance(json_body, dict) else {} diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py index 9bf87f7..2b1d47e 100644 --- a/python/tests/test_contract.py +++ b/python/tests/test_contract.py @@ -20,15 +20,15 @@ def _dispatch(channel="sms"): @pytest.mark.parametrize("channel", ["sms", "voice"]) -def test_soprano_exact_sms_and_voice_contract(channel): +def test_soprano_uses_selected_endpoint_and_oauth(channel): request = ProviderRegistry([SopranoProvider()]).get("SOPRANO").build_request( - channel, "https://qa4.example/cgpapi///", _dispatch(channel), - {"mode": "apiKey", "identity": "test-id", "secret": "test-key"}, + channel, "https://qa4.example/oauth/messages", _dispatch(channel), + {"mode": "oauth", "access_token": "provider-token"}, {}, ) - assert request["url"] == "https://qa4.example/cgpapi/messages/omnimsg" and request["method"] == "POST" + assert request["url"] == "https://qa4.example/oauth/messages" and request["method"] == "POST" assert request["headers"] == { - "X-MEMS-API-ID": "test-id", "X-MEMS-API-Key": "test-key", + "Authorization": "Bearer provider-token", "Content-Type": "application/json", "Accept": "application/json", } assert json.loads(request["body"]) == { @@ -59,10 +59,10 @@ def test_infobip_sms_request_and_response_contract(): def test_telesign_sms_request_and_response_contract(): request = TelesignProvider().build_request( - "sms", "https://telesign.example", _dispatch(), + "sms", "https://telesign.example/epp/sms", _dispatch(), {"mode": "apiKey", "secret": "key", "identity": "customer"}, {}, ) - assert request["method"] == "POST" and request["url"] == "https://telesign.example/v1/messaging" + assert request["method"] == "POST" and request["url"] == "https://telesign.example/epp/sms" assert request["headers"]["Authorization"] == "Basic " + base64.b64encode(b"customer:key").decode() assert request["headers"]["Content-Type"] == "application/x-www-form-urlencoded" form = parse_qs(request["body"]) diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 6d352f8..d4f4edd 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -18,15 +18,20 @@ def _request(channel="sms"): def engine(monkeypatch): registry = ProviderRegistry([SopranoProvider(), SinchProvider()]) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) - return DispatchEngine(registry, Mock(resolve=Mock(return_value="test-key")), - {"EPP_PROVIDER_NAME": " SOPRANO ", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi/"}) + result = DispatchEngine(registry, Mock(resolve=Mock(return_value="test-key")), { + "EPP_PROVIDER_NAME": " SOPRANO ", + "EPP_PROVIDER_ENDPOINT": "https://qa4.example/oauth/messages", + "EPP_PROVIDER_AUTH_MODE": "oauth", + "EPP_PROVIDER_CHANNEL": "sms", + }) + result._resolve_credential = Mock(return_value={"mode": "oauth", "access_token": "provider-token"}) + return result -def test_missing_key_or_identity_never_sends(engine): - for missing in ("soprano-api-key", "soprano-api-id"): - engine.secrets.resolve.side_effect = lambda name: None if name == missing else "test-key" - status, body = engine.dispatch(_request(), "r") - assert status == 502 and body["reason"] == "provider credential unavailable" +def test_missing_oauth_configuration_never_sends(engine): + engine._resolve_credential = DispatchEngine._resolve_credential.__get__(engine, DispatchEngine) + status, body = engine.dispatch(_request(), "r") + assert status == 502 and body["reason"] == "provider credential unavailable" dispatch_module.requests.request.assert_not_called() @@ -37,6 +42,9 @@ def test_base_and_sinch_voice_final_url_guards(engine): assert status == 502 and body["reason"] == "invalid provider endpoint" engine.env["EPP_PROVIDER_ENDPOINT"] = "https://api.example" engine.env["EPP_PROVIDER_NAME"] = "sinch" + engine.env.pop("EPP_PROVIDER_CHANNEL", None) + engine.env.pop("EPP_PROVIDER_AUTH_MODE", None) + engine._resolve_credential = Mock(return_value={"mode": "apiKey", "secret": "test-key", "identity": ""}) for url in ("http://voice.example", "https://voice.example:0"): engine.env["SINCH_VOICE_ENDPOINT"] = url status, body = engine.dispatch(_request("voice"), "r") diff --git a/python/tests/test_function_app.py b/python/tests/test_function_app.py index 07a088d..f4b5903 100644 --- a/python/tests/test_function_app.py +++ b/python/tests/test_function_app.py @@ -34,8 +34,10 @@ def _isolate(monkeypatch): monkeypatch.setattr(function_app, "_key_provider", Mock(return_value=_PRIVATE_PEM)) engine = dispatch_module.DispatchEngine( function_app._registry, Mock(resolve=Mock(return_value="test-key")), - {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/cgpapi"}, + {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_ENDPOINT": "https://qa4.example/oauth/messages", + "EPP_PROVIDER_AUTH_MODE": "oauth"}, ) + engine._resolve_credential = Mock(return_value={"mode": "oauth", "access_token": "provider-token"}) monkeypatch.setattr(function_app, "_engine", engine) monkeypatch.setattr(dispatch_module.requests, "request", Mock()) diff --git a/CYOT-Setup/.gitignore b/setup/.gitignore similarity index 88% rename from CYOT-Setup/.gitignore rename to setup/.gitignore index 05fd85d..dff0c96 100644 --- a/CYOT-Setup/.gitignore +++ b/setup/.gitignore @@ -4,3 +4,4 @@ state/* !state/.gitkeep policy-backups/* !policy-backups/.gitkeep +epp-output/ diff --git a/setup/EPP-Setup.psd1 b/setup/EPP-Setup.psd1 new file mode 100644 index 0000000..3e158c4 --- /dev/null +++ b/setup/EPP-Setup.psd1 @@ -0,0 +1,14 @@ +@{ + PackageName = 'EPP endpoint deployment' + PackageVersion = '0.3.0' + EntryPoint = 'Setup-Epp.ps1' + MinimumPowerShellVersion = '7.0' + Support = @('support/Epp.Setup.psm1', 'support/Epp.Packages.ps1') + Infrastructure = @( + 'infra/main.bicep' + 'infra/resources.bicep' + ) + ProviderCatalog = 'providers/catalog.json' + PackageCatalog = 'packages/catalog.json' + RuntimeDirectories = @('epp-output') +} diff --git a/setup/Setup-Epp.ps1 b/setup/Setup-Epp.ps1 new file mode 100644 index 0000000..f313c5d --- /dev/null +++ b/setup/Setup-Epp.ps1 @@ -0,0 +1,84 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Download the EPP deployment tools, collect settings, and review one deployment plan. +.DESCRIPTION + Download only this file. Supporting PowerShell, Bicep, and provider JSON files come from + the selected public GitHub repository (Azure-Samples by default). + Application registration and policy activation are manual steps. + No Azure resources are changed until you approve the complete plan. +.PARAMETER SourceRepository + Public GitHub owner/repository containing the setup files. Use with SourceRef to test a fork. +.EXAMPLE + .\Setup-Epp.ps1 +.EXAMPLE + .\Setup-Epp.ps1 -TenantId -SubscriptionId -ApplicationId +#> +[CmdletBinding()] +param( + [string] $TenantId, + [string] $SubscriptionId, + [string] $ApplicationId, + [string] $Location, + [string] $Provider, + [string] $Channel, + [string] $EndpointRegion, + [string] $ProviderAccountName, + [string] $ResourcePrefix, + [string] $Language, + [string] $OutputDirectory = (Join-Path $PSScriptRoot 'epp-output'), + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', + [string] $SourceRef = 'main', + [switch] $NonInteractive, + [switch] $ApproveDeployment +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$repository = $SourceRepository +$arguments = @{} + $PSBoundParameters +$arguments.Remove('SourceRef') +$arguments.OutputDirectory = $OutputDirectory +$arguments.SourceRepository = $SourceRepository +$downloadDirectory = Join-Path ([IO.Path]::GetTempPath()) "epp-download-$([Guid]::NewGuid().ToString('N'))" +$module = $null + +try { + # Resolve once so a branch update cannot mix scripts, templates, and provider profiles. + $revision = $SourceRef + if ($revision -notmatch '^[0-9a-fA-F]{40}$') { + $commit = Invoke-RestMethod -Uri "https://api.github.com/repos/$repository/commits/$([Uri]::EscapeDataString($SourceRef))" ` + -Headers @{ 'User-Agent' = 'EPP-Setup'; Accept = 'application/vnd.github+json' } -TimeoutSec 60 + $revision = $commit.sha + } + if ($revision -notmatch '^[0-9a-fA-F]{40}$') { throw 'GitHub did not return a valid commit ID.' } + $sourceBaseUri = "https://raw.githubusercontent.com/$repository/$revision/setup" + Write-Host "Downloading deployment tools from $repository at $revision" + + foreach ($file in @('support/Epp.Setup.psm1', 'support/Epp.Packages.ps1', 'providers/catalog.json', 'packages/catalog.json', 'infra/main.bicep', 'infra/resources.bicep')) { + $destination = Join-Path $downloadDirectory $file + New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null + Invoke-WebRequest -Uri "$sourceBaseUri/$file" -OutFile $destination -TimeoutSec 60 -MaximumRedirection 0 + if ((Get-Item -LiteralPath $destination).Length -eq 0) { throw "GitHub returned an empty file: $file" } + } + + $module = Import-Module (Join-Path $downloadDirectory 'support/Epp.Setup.psm1') -PassThru -Force + Invoke-EppSetup @arguments -AssetDirectory $downloadDirectory -SourceBaseUri $sourceBaseUri +} +finally { + try { + if ($module) { Remove-Module -ModuleInfo $module -ErrorAction Stop } + } + catch { + Write-Warning "Could not unload the temporary EPP helper: $($_.Exception.Message)" -WarningAction Continue + } + try { + if (Test-Path -LiteralPath $downloadDirectory) { + Remove-Item -LiteralPath $downloadDirectory -Recurse -Force -ErrorAction Stop + } + } + catch { + Write-Warning "Could not remove temporary downloads at '$downloadDirectory': $($_.Exception.Message)" -WarningAction Continue + } +} diff --git a/setup/docs/README.md b/setup/docs/README.md new file mode 100644 index 0000000..ede36fe --- /dev/null +++ b/setup/docs/README.md @@ -0,0 +1,228 @@ +# EPP endpoint setup + +**Only Step 2 is scripted.** Register the customer application manually, run one downloaded +PowerShell script to deploy the endpoint, and activate policy manually after validation. + +The customer does not clone this repository or download Bicep/support scripts separately. +`Setup-Epp.ps1` retrieves those files and the selected provider's JSON from GitHub. + +## Availability + +Choose **JavaScript, .NET, or Python**, then **Telesign or Soprano**, **SMS or voice**, and a +**Global or EU endpoint**. The private test branch uses its matching fork preview release so the +package and provider-authentication contract stay in sync. There is no package URL or checksum to +enter. Setup verifies `SHA256SUMS.txt` automatically and performs the required build and publication +for the selected language. + +Provider profiles contain complete channel/region route objects. Unknown values use **explicit dummy +test values**, not a separate placeholder list or empty fields that block setup. They are written +into the Function App's **actual environment settings** after approval. Telesign's supplied route +URLs and timings are preserved. Soprano uses labelled test tenant, scope, app-ID, endpoint, and +timing values. +The plan and saved summary identify test configuration. Deployment does not make these values +working endpoints or credentials. The provider files contain the complete deployment contract. + +The default download URLs below become usable when this change is published upstream. Before merging, +test from a published public fork using `-SourceRepository ` and +`-SourceRef `. Both options must identify the same source as the downloaded +launcher. Unpublished worktree changes are not downloadable from GitHub. + +## Step 1 - manually register and onboard the application + +Use a dedicated nonproduction tenant/subscription for the first deployment. + +1. In the customer tenant's **Microsoft Entra admin center > App registrations**, register a + dedicated application. Select **Accounts in any organizational directory**; do not enable + personal Microsoft accounts. No redirect URI or client secret is needed for this endpoint. +2. Record the **Directory (tenant) ID** and **Application (client) ID**. The script requires the + client ID, not the application's object ID, and will not create a replacement registration. +3. Verify the application's enterprise application exists in the same tenant. In **Enterprise + applications > Properties**, set **Assignment required?** to **No** as required by EPP onboarding. + Easy Auth will still pin inbound calls to the Microsoft phone-provider application + `25ec60fa-f18d-41a4-b398-50044c90ce13`; this is not permission to accept arbitrary callers. +4. Verify the app's access-token version in its manifest. Setup reads `api.requestedAccessTokenVersion` + and configures the corresponding v1 or v2 issuer/audience. Leave **`tokenEncryptionKeyId` null**: + Easy Auth expects a signed bearer JWT. Payload JWE encryption is separate. +5. Complete provider purchase, account/sender registration, and onboarding for the selected adapter. + Telesign uses `telesign-api-key` and `telesign-customer-id` in Key Vault. Soprano uses OAuth + client-assertion exchange with the selected provider tenant/scope/application ID. Setup does not + grant provider API consent or application roles. + +Step 2 still configures endpoint-specific properties on this **existing** application: its +hostname-based identifier URI and public JWE encryption certificate. Those changes are included +in the single deployment approval. Soprano additionally creates the disclosed outbound +managed-identity federated credential; Telesign does not. + +## Prerequisites for Step 2 + +- **Windows with PowerShell 7+**. Certificate generation/reuse uses the current user's Windows + certificate store; this is not an Azure Cloud Shell or Linux customer deployment script. +- Azure CLI **2.48.1+** and its Bicep compiler, with access to GitHub, Azure, Microsoft Graph, and + Key Vault. Python additionally needs network access to the Function App's SCM endpoint. +- Microsoft Graph PowerShell modules `Microsoft.Graph.Authentication` and `Microsoft.Graph.Applications`. +- An Azure **user** account permitted to deploy at subscription scope, create the listed resources, + and create the scoped role assignments. Service-principal provisioning is not supported. +- Application-management permission in the customer tenant and delegated Graph + `Application.ReadWrite.All` for endpoint-specific application configuration. +- **Linux Premium EP1** available in the chosen region. Setup registers missing required Azure + resource providers automatically after the single approval. The Azure account needs the + providers' subscription-scoped `/register/action` permission (included in Contributor/Owner). +- **.NET selection only:** install the .NET 8 SDK and allow NuGet access. Setup runs `dotnet publish` + automatically for `linux-x64`, packages the publish output, and deploys it. No manual build step + or upload is required. JavaScript and Python do not require this SDK. +- **Python selection:** Azure performs the Linux dependency build. No local Python, pip, or Windows + dependency installation is needed. The source archive is never used directly as run-from-package. + +| Choice | Azure runtime | Automatic deployment path | +|---|---|---| +| JavaScript | Node.js 22, Functions v4 | Verify and publish the ready ZIP with its production dependencies | +| .NET | .NET 8 isolated, Functions v4 | Verify source ZIP, publish for Linux with .NET 8, repackage and publish | +| Python | Python 3.11, Functions v4 | Verify source ZIP, request Azure remote build, validate/download built output, publish that output | + +Package hashes are still checked; removing the **customer prompt** does not disable integrity +verification. Source and deployed-package hashes are recorded separately when a build changes the bytes. + +Install prerequisites once, if missing: + +```powershell +Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Repository PSGallery +Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Repository PSGallery +az bicep install +``` + +Install Azure CLI through its official installation instructions if necessary. Sign in before +running setup; Azure CLI and Microsoft Graph have separate authentication sessions: + +```powershell +az login --tenant +``` + +Setup checks the explicitly supplied subscription and tenant without changing the CLI's selected +subscription. It requests Graph sign-in before displaying the plan if a suitable delegated +session is not already available. Authentication/MFA prompts are not resource-creation approvals. + +## Step 2 - download and run one script + +Download and inspect [Setup-Epp.ps1](../Setup-Epp.ps1), or save it from the upstream raw URL: + +```powershell +Invoke-WebRequest ` + -Uri 'https://raw.githubusercontent.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample/main/setup/Setup-Epp.ps1' ` + -OutFile .\Setup-Epp.ps1 +.\Setup-Epp.ps1 +``` + +The flow is: + +1. **Collect missing customer inputs:** tenant, subscription, existing application client ID, Azure + region, and provider account/sender name. Supplied values + are reused without prompts. Credentials are never requested as ordinary string parameters. +2. **Choose one language**. Setup looks up its GitHub release and checksum file in + `packages/catalog.json`; there are no `PackageUrl` or `PackageSha256` inputs. +3. **Choose a provider**, then **SMS or voice**, then **Global or EU endpoint**. Setup downloads the + provider JSON and resolves one complete route containing endpoint, authentication, app-ID/scope + when applicable, timeout, and retry interval. Explicit test values are allowed, shown as test + configuration, and passed to Azure settings. Malformed or disabled profiles still fail before + resource creation. +4. **Enter a resource prefix**, such as `contoso`: 2-8 lowercase letters/digits, starting with a + letter. Every top-level resource name then adds the meaningful `epp` marker, for example + `contoso-epp-rg-`. A deterministic suffix derived from the + subscription, application ID, and prefix reduces global-name collisions. Reruns use the same names. +5. **Review the complete plan**, including resource names, tenant/subscription, language, automatic + package verification/build, provider + settings, scoped roles, certificate creation, and application configuration. Bicep receives these + exact names; it does not independently calculate a different naming scheme. + The plan also lists the six required **Azure resource providers** and their registration states. + This is separate from the Telesign/Soprano provider selection. +6. **Type `Yes` once to deploy.** `No` or Enter cancels without Azure changes. Invalid answers prompt + again; individual resources do not request additional approvals. + +After approval, setup rechecks the selected subscription and registers only missing +`Microsoft.Web`, `Microsoft.Storage`, `Microsoft.KeyVault`, `Microsoft.OperationalInsights`, +`Microsoft.Insights`, and `Microsoft.ManagedIdentity` providers. Already registered providers are +left alone; existing registrations in progress are reused. Registration and regional checks happen +before certificate creation or Bicep deployment. The read-only preflight does not register anything. + +Azure registers providers region by region. Setup does not unnecessarily wait for a global +`Registered` state when a provider is already `Registering` and exposes the requested region. +Registration metadata is polled with a bounded limit, and recognized regional registration +propagation errors are retried during capability checks/deployment. Permission failures and +unsupported regions remain explicit errors. Registration is subscription-wide and isn't undone +automatically if a later deployment step fails. + +Supply known values to shorten the prompts: + +```powershell +.\Setup-Epp.ps1 ` + -TenantId ` + -SubscriptionId ` + -ApplicationId ` + -Location westus2 ` + -Language javascript ` + -Provider telesign ` + -ResourcePrefix contoso +``` + +The plan creates or updates a dedicated resource group, Linux Premium EP1 hosting plan, Function App, +storage account/private package container, Key Vault, Log Analytics workspace, Application Insights, +outbound managed identity, diagnostics, Easy Auth, and scoped role assignments. Storage/package +access uses managed identity, not account keys or SAS. Telemetry uses the system identity; the +outbound identity is selected explicitly, not through a global `AZURE_CLIENT_ID`. + +The Function starts with public ingress disabled. Setup stores the private key in Key Vault and +configures application trust. It **reads back and verifies Easy Auth before enabling ingress**. +Python requires this access for its Entra-authenticated SCM remote build; SCM basic authentication +stays disabled. Setup validates the built Python payload, stores it in private Blob storage, and +switches to managed-identity run-from-package. It never mounts the unbuilt Python source ZIP. +For every language, setup restarts, synchronizes triggers, and verifies that `SendOtp` is registered. +On publication/startup failure it disables public ingress again; failure to close ingress is reported +explicitly rather than hidden. + +The public certificate and a timestamped identifier +summary are saved to `epp-output` beside the downloaded script, or to `-OutputDirectory`. +Private keys remain in the user's certificate store and Key Vault, not in that summary. With dummy +profiles, `EPP_PROVIDER_TEST_CONFIGURATION=true` is stored alongside the real environment settings. +This is a label, not a replacement for caller authentication or a guarantee of provider connectivity. + +For unattended runs, supply every input, authenticate both clients first, and explicitly authorize +the whole displayed plan with **both** `-NonInteractive -ApproveDeployment`. `-NonInteractive` +alone never approves changes. There is no `-Stage`, `-Resume`, `-ConfigPath`, or policy-approval switch. + +### Source versioning + +`-SourceRepository` defaults to `Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample`. +The small entry point resolves `-SourceRef` (default `main`) to a single commit in that repository. All supporting +PowerShell, Bicep, the catalog, and the selected provider profile are downloaded from that commit. +Use a reviewed full commit SHA for repeatable deployments. Provider JSON selects data only; it +cannot redirect execution to another script. Download failures stop setup, and temporary downloads +are removed on completion or failure. Select only a repository whose code you trust: its supporting +PowerShell is executed locally. + +## Step 3 - manually validate and activate policy + +1. Save the Step 2 summary and confirm its tenant, application client ID, endpoint URL, encryption + key ID, and certificate with the EPP onboarding owner. **Replace all test provider values** and + provision the adapter-named API credentials in Key Vault. Verify the package's channel routing + and retry behavior; the tenant/scope metadata and test label do not enable unsupported behavior. +2. Validate the deployed endpoint with synthetic, non-delivering evaluation requests first. + Missing/invalid credentials and unauthorized callers must be rejected by Easy Auth. An admitted + caller's valid encrypted request must return the matching nonce. Then verify live SMS/voice + provider acceptance and handset delivery through the supported test procedure. Never put + phone numbers, messages, tokens, private keys, or nonce values in shared logs. +3. An **Authentication Policy Administrator**, using the approved Microsoft Graph tool and delegated + `Policy.ReadWrite.AuthenticationMethod`, must verify that the tenant's currently supported EPP + contract is available. For the preview contract formerly handled by Step 3, inspect + `https://graph.microsoft.com/beta/$metadata` for `authenticationMethodsPolicy.cyot` and its + `endpoint`, `appId`, and `migrated` fields. **If absent or different, stop and obtain the supported + onboarding procedure from Microsoft; do not send a guessed PATCH or enable a different method.** +4. Read `https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy` using that supported + contract, save the existing `cyot` value with tenant ID and timestamp, and independently approve + the migration choice. `migrated` is a routing decision, not a script default. +5. Re-read immediately before a manual change, stop if the policy changed, and use `If-Match` when + an ETag is available. Patch **only** the `cyot` property with the tested endpoint, the same + application client ID, and the deliberately chosen migration Boolean. Read it back and compare + before considering activation complete. + +Policy activation, policy backups, and policy rollback are administrator-owned manual operations. +No policy API is called by the setup package. For rollback, restore only the reviewed prior EPP +value through the still-supported contract; resource deletion is not a policy rollback. \ No newline at end of file diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md new file mode 100644 index 0000000..5eff6e7 --- /dev/null +++ b/setup/docs/Troubleshooting.md @@ -0,0 +1,166 @@ +# Troubleshooting Step 2 + +## appservice list-locations rejects EP1 + +`EP1` is an Azure Functions Elastic Premium plan SKU, but older Azure CLI versions do not accept +it in the `az appservice list-locations --sku` command. The current setup uses the subscription-scoped +`Microsoft.Web/geoRegions` ARM API with `sku=ElasticPremium` and `linuxWorkersEnabled=true` instead. +Query parameters are passed in a file to avoid Windows command-shell escaping problems. +The actual deployment remains **EP1**; it is not changed to a Dedicated App Service Premium SKU. + +The accompanying 32-bit Python cryptography message is a performance warning, not the cause of +the invalid-SKU error. Rerun with the updated test-branch helper; changing the SKU or installing +another Python runtime is not required to fix this check. + +## A required Azure resource provider is not registered + +The current setup detects missing providers such as `Microsoft.Web` during read-only preflight +and lists them in the resource plan instead of asking the customer to register them manually. +After `Yes` (or explicit noninteractive approval), it registers only the six namespaces needed by +this deployment in the supplied subscription. No registration occurs if approval is declined. + +Already registered providers are skipped. `Registering` is not a failure: Azure registers each +region separately, so setup proceeds when the needed region is exposed and retries recognized +registration-propagation errors. Metadata polling is limited to 60 checks with 10-second pauses; +regional propagation retries are limited to 12 attempts. An actively `Unregistering` provider is +not reversed automatically. + +If registration fails, inspect the original Azure CLI error. The account needs subscription-scoped +resource-provider `/register/action` permission, generally included in Contributor or Owner. +Setup cannot grant this permission or bypass a subscription policy. It stops before creating the +certificate or deployment resources. Registrations already requested are left in place for a rerun; +the script does not unregister services that other workloads might now use. + +## Get-MgContext reports SessionNotInitialized + +This is different from simply not being signed in. A failed attempt to remove Graph Authentication +can run the SDK's cleanup hook and clear its internal session even though Graph Applications keeps +the module loaded. Reimporting an already loaded module normally does not initialize it again. +See the upstream [Graph SDK issue](https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/2457). + +Setup now detects this exact error during its initial context check, reloads the **same loaded +Authentication version** once, and then uses the normal sign-in flow. It does not force-remove the +SDK, upgrade modules, suppress unrelated errors, or automatically reconnect after deployment approval. +A healthy existing Graph session is reused unchanged. Noninteractive runs still require prior sign-in. + +For immediate recovery, start a new process with `pwsh -NoProfile` and rerun the downloaded script. +If initialization still fails after the one reload, setup gives this same clean-process instruction +instead of repeatedly retrying or hiding the error. + +## Remove-Module says Graph Authentication is required by Graph Applications + +Older setup versions imported the Graph SDK inside the temporary EPP module. Unloading that +helper could then attempt to remove its Graph dependencies in the wrong order, producing this +cleanup error. The current version imports both Graph modules into the PowerShell session's global +scope and unloads only its temporary EPP helper. Your Graph modules and sign-in context remain +available for subsequent commands and reruns. + +Do not add `-Force` to remove the Graph SDK. Download the updated launcher and open a fresh +PowerShell 7 window to discard module state left by the old version. Cleanup failures are now +reported as warnings, temporary-file cleanup is attempted independently, and an earlier setup +error is preserved. A cleanup error alone does not establish whether Azure deployment succeeded; +review the original output and saved deployment summary. + +## Setup still asks for PackageUrl or PackageSha256 + +You are running an older launcher or source revision. Download `Setup-Epp.ps1` again and supply +the intended `-SourceRepository` and `-SourceRef`. The current version asks for **one language** +and reads its package URL and published checksum automatically. Remove old package URL/hash +arguments from saved commands. + +## Provider settings are dummy values + +This is intentional for deployment testing. Both JSON profiles explicitly use +`deployment.testConfiguration: true`. Every SMS/voice and Global/EU route is complete; zero GUIDs +and `example.invalid` URLs are written into the actual Function App environment when that route is +selected, with `EPP_PROVIDER_TEST_CONFIGURATION=true`. Telesign's supplied channel URLs and timings +are retained. + +The script can deploy code with these values, but dummy routes cannot deliver real messages. +Update the provider-owned profile before live use. Telesign requires its API-key secrets in Key +Vault. Soprano uses the selected OAuth tenant/scope/app ID and outbound managed-identity federation; +provider consent and API roles remain external onboarding steps. + +## A checksum or package download fails + +Each language entry points to a versioned GitHub ZIP and the same release's `SHA256SUMS.txt`. +The file must contain exactly one valid entry for that asset. Missing, duplicate, malformed, or +mismatched checksums fail closed; there is no manual-hash or skip-verification workaround. +Verify the catalog's links and your access to GitHub/release assets. + +Supporting tools, Bicep, catalogs, and provider JSON all come from the commit selected at startup. +For a public-fork branch, pass both source options. A full commit SHA avoids branch-resolution +API rate limits. Private repositories are not supported by these unauthenticated raw downloads. + +## .NET build fails + +Install the **.NET 8 SDK** and allow NuGet access. Setup selects an installed 8.x SDK, extracts the +verified source into its temporary workspace, runs a Linux-targeted Release publish, checks the +publish output, and creates the ready ZIP. The source ZIP is not uploaded as runnable code. +Build failures occur before Azure resource creation and include the `dotnet` failure output. + +Do not manually replace the published source checksum with a hash of the build output. These +represent different artifacts; setup computes the built artifact's hash itself. + +## Python remote build fails + +Use Azure CLI **2.48.1+** with a user account allowed to publish to the Function App and network +access to its SCM endpoint. Setup enables `SCM_DO_BUILD_DURING_DEPLOYMENT` and `ENABLE_ORYX_BUILD`, +without `WEBSITE_RUN_FROM_PACKAGE` during the build, and requests Azure remote build explicitly. +It never installs Windows Python dependencies for the Linux app. + +SCM basic authentication remains disabled. The CLI uses Microsoft Entra authentication. The built +`site/wwwroot` snapshot must include the Python Functions dependency payload; an unbuilt source +archive is rejected even when an upload command returned success. The built output is then stored +in private Blob storage, and temporary remote-build settings are cleared. + +If build, snapshot, publication, or startup fails after opening SCM ingress, setup attempts to +disable public ingress again. An inability to close ingress is an explicit error requiring +immediate administrator inspection. Do not bypass certificate errors or enable basic auth. + +## Azure CLI warnings break JSON parsing + +The current helper separates stdout from stderr. Successful command JSON is parsed independently +of SDK warnings, while stderr warnings are shown and nonzero exit codes still fail. Upgrade an +older downloaded helper by refreshing the launcher/source revision. + +## Authentication, permission, or runtime preflight fails + +Use PowerShell 7 on Windows, Azure CLI with Bicep, and the documented Graph modules. Sign into the +customer tenant with a user account. ARM requests use the supplied subscription; setup does not +change the CLI's default subscription or adopt unrelated resource groups. + +The customer application and enterprise application must already exist from manual Step 1. +Graph needs delegated `Application.ReadWrite.All` for endpoint URI/key configuration. Noninteractive +runs must authenticate both clients first and supply `-ApproveDeployment` separately. +Use a distinct resource prefix for each language; setup rejects changing a previously tagged +app to another runtime with the same prefix. + +## Deployment stops after approval + +Some resources can remain. No automatic deletion, vault purge/recovery, policy activation, or +rollback occurs. Inspect the named Azure deployment and the reported error, then rerun with the +same tenant, subscription, application, language, and prefix after correcting it. + +Recognized storage/Key Vault RBAC propagation errors are retried for at most twelve attempts. +Transient Function startup errors also have bounded retries. This includes the specific ARM +`BadRequest` response `Encountered an error (InternalServerError) from host runtime`, which Azure can +return while a newly restarted host is still loading an otherwise valid package. Generic +`InternalServerError` responses are not retried. A successful upload alone is not success: +`SendOtp` must appear in Azure's function metadata. No success summary is written if publication or +registration fails. + +If setup exhausts the retries, inspect Application Insights for host initialization, worker startup, +and function discovery errors before rerunning. The expected healthy sequence includes `Worker process +started and initialized`, `Found the following functions: Host.Functions.SendOtp`, and `Job host +started`. Setup closes public ingress after a persistent publication failure. + +## The endpoint returns 401 or live delivery fails + +Keep Easy Auth enabled. Check the trusted tenant, actual token version, audience, HTTPS requirement, +and nonempty Microsoft caller allowlist. Keep `tokenEncryptionKeyId` null on the endpoint app; +payload JWE encryption is separate from signed bearer-token validation. + +For live delivery, replace dummy endpoints and configure the provider's exact Key Vault secret +names. Test with synthetic evaluation requests before live messages. EPP policy remains a +separate, administrator-approved manual operation; no setup code updates it. diff --git a/setup/infra/main.bicep b/setup/infra/main.bicep new file mode 100644 index 0000000..12d9838 --- /dev/null +++ b/setup/infra/main.bicep @@ -0,0 +1,55 @@ +targetScope = 'subscription' + +@description('The exact resource names displayed in the approved setup plan.') +param resourceNames object + +param location string +param tenantId string +param applicationId string +param callerApplicationId string +param deployerObjectId string +param providerSettings object +param packageBlobName string +@allowed(['javascript', 'dotnet', 'python']) +param language string +param remoteBuild bool + +@allowed([1, 2]) +param tokenVersion int + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceNames.resourceGroup + location: location + tags: { + managedBy: 'EPP-Setup' + eppApplicationId: applicationId + eppLanguage: language + } +} + +module endpoint 'resources.bicep' = { + name: 'epp-endpoint' + scope: resourceGroup + params: { + resourceNames: resourceNames + location: location + tenantId: tenantId + applicationId: applicationId + callerApplicationId: callerApplicationId + deployerObjectId: deployerObjectId + tokenVersion: tokenVersion + providerSettings: providerSettings + packageBlobName: packageBlobName + language: language + remoteBuild: remoteBuild + } +} + +output resourceGroupName string = resourceGroup.name +output functionAppName string = endpoint.outputs.functionAppName +output storageAccountName string = endpoint.outputs.storageAccountName +output keyVaultName string = endpoint.outputs.keyVaultName +output outboundPrincipalId string = endpoint.outputs.outboundPrincipalId +output endpointUrl string = endpoint.outputs.endpointUrl +output identifierUri string = endpoint.outputs.identifierUri +output packageContainerUrl string = endpoint.outputs.packageContainerUrl diff --git a/setup/infra/resources.bicep b/setup/infra/resources.bicep new file mode 100644 index 0000000..72dbd7c --- /dev/null +++ b/setup/infra/resources.bicep @@ -0,0 +1,335 @@ +param resourceNames object +param location string +param tenantId string +param applicationId string +param callerApplicationId string +param deployerObjectId string +param tokenVersion int +param providerSettings object +param packageBlobName string +param language string +param remoteBuild bool + +var runtimes = { + javascript: { + worker: 'node' + stack: 'NODE|22' + } + dotnet: { + worker: 'dotnet-isolated' + stack: 'DOTNET-ISOLATED|8.0' + } + python: { + worker: 'python' + stack: 'PYTHON|3.11' + } +} +var runtime = runtimes[language] + +var tags = { + managedBy: 'EPP-Setup' + eppApplicationId: applicationId + eppLanguage: language +} +var blobDataOwnerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') +var blobDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') +var queueDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') +var tableDataContributorRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3') +var keyVaultSecretsUserRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var keyVaultSecretsOfficerRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') +var monitoringMetricsPublisherRoleId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: resourceNames.logAnalytics + location: location + tags: tags + properties: { + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource outboundIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: resourceNames.outboundIdentity + location: location + tags: tags +} + +resource insights 'Microsoft.Insights/components@2020-02-02' = { + name: resourceNames.applicationInsights + location: location + kind: 'web' + tags: tags + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + DisableLocalAuth: true + IngestionMode: 'LogAnalytics' + RetentionInDays: 30 + } +} + +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: resourceNames.storageAccount + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + accessTier: 'Hot' + allowBlobPublicAccess: false + allowCrossTenantReplication: false + allowSharedKeyAccess: false + defaultToOAuthAuthentication: true + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Enabled' + supportsHttpsTrafficOnly: true + } +} + +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { + parent: storage + name: 'default' +} + +resource packages 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: 'packages' + properties: { + publicAccess: 'None' + } +} + +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { + name: resourceNames.keyVault + location: location + tags: tags + properties: { + tenantId: tenantId + enableRbacAuthorization: true + enablePurgeProtection: true + enableSoftDelete: true + softDeleteRetentionInDays: 90 + publicNetworkAccess: 'Enabled' + sku: { + family: 'A' + name: 'standard' + } + } +} + +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: resourceNames.hostingPlan + location: location + kind: 'linux' + tags: tags + sku: { + name: 'EP1' + tier: 'ElasticPremium' + capacity: 1 + } + properties: { + reserved: true + maximumElasticWorkerCount: 3 + } +} + +resource functionApp 'Microsoft.Web/sites@2024-04-01' = { + name: resourceNames.functionApp + location: location + kind: 'functionapp,linux' + tags: tags + identity: { + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${outboundIdentity.id}': {} + } + } + properties: { + serverFarmId: plan.id + httpsOnly: true + // The script verifies Easy Auth before opening ingress for publication or Python remote build. + publicNetworkAccess: 'Disabled' + siteConfig: { + alwaysOn: true + minimumElasticInstanceCount: 1 + ftpsState: 'Disabled' + http20Enabled: true + linuxFxVersion: runtime.stack + minTlsVersion: '1.2' + } + } +} + +var identifierUri = 'api://${functionApp.properties.defaultHostName}/${applicationId}' +var issuer = tokenVersion == 2 ? '${environment().authentication.loginEndpoint}${tenantId}/v2.0' : 'https://sts.windows.net/${tenantId}/' +var audience = tokenVersion == 2 ? applicationId : identifierUri + +resource appSettings 'Microsoft.Web/sites/config@2024-04-01' = { + parent: functionApp + name: 'appsettings' + properties: union(providerSettings, { + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: runtime.worker + AzureWebJobsStorage__accountName: storage.name + AzureWebJobsStorage__credential: 'managedidentity' + APPLICATIONINSIGHTS_CONNECTION_STRING: insights.properties.ConnectionString + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'Authorization=AAD' + KEY_VAULT_URL: vault.properties.vaultUri + EPP_DECRYPTION_KEY_PEM: '@Microsoft.KeyVault(SecretUri=${vault.properties.vaultUri}secrets/phone-provider-decryption-key)' + EPP_OUTBOUND_CLIENT_ID: applicationId + EPP_OUTBOUND_MI_CLIENT_ID: outboundIdentity.properties.clientId + EPP_EXPECTED_AUDIENCE: audience + EPP_EXPECTED_ISSUER: issuer + EPP_EXPECTED_CLIENT_ID: callerApplicationId + EPP_TENANT_ID: tenantId + }, remoteBuild ? { + SCM_DO_BUILD_DURING_DEPLOYMENT: 'true' + ENABLE_ORYX_BUILD: 'true' + } : { + WEBSITE_RUN_FROM_PACKAGE: '${storage.properties.primaryEndpoints.blob}${packages.name}/${packageBlobName}' + WEBSITE_RUN_FROM_PACKAGE_BLOB_MI_RESOURCE_ID: 'SystemAssigned' + SCM_DO_BUILD_DURING_DEPLOYMENT: 'false' + ENABLE_ORYX_BUILD: 'false' + }) +} + +resource authentication 'Microsoft.Web/sites/config@2024-04-01' = { + parent: functionApp + name: 'authsettingsV2' + properties: { + platform: { + enabled: true + } + globalValidation: { + requireAuthentication: true + unauthenticatedClientAction: 'Return401' + excludedPaths: [] + } + httpSettings: { + requireHttps: true + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: applicationId + openIdIssuer: issuer + } + validation: { + allowedAudiences: [audience] + defaultAuthorizationPolicy: { + allowedApplications: [callerApplicationId] + } + } + } + } + login: { + tokenStore: { + enabled: false + } + } + } +} + +resource systemStorageRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for roleId in [ + blobDataOwnerRoleId + queueDataContributorRoleId + tableDataContributorRoleId +]: { + name: guid(storage.id, functionApp.id, roleId) + scope: storage + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: roleId + } +}] + +resource packageUploadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storage.id, deployerObjectId, blobDataContributorRoleId) + scope: storage + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: blobDataContributorRoleId + } +} + +resource vaultReadRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, functionApp.id, keyVaultSecretsUserRoleId) + scope: vault + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleId + } +} + +resource vaultWriteRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vault.id, deployerObjectId, keyVaultSecretsOfficerRoleId) + scope: vault + properties: { + principalId: deployerObjectId + principalType: 'User' + roleDefinitionId: keyVaultSecretsOfficerRoleId + } +} + +resource metricsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(insights.id, functionApp.id, monitoringMetricsPublisherRoleId) + scope: insights + properties: { + principalId: functionApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: monitoringMetricsPublisherRoleId + } +} + +resource functionDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: 'send-to-log-analytics' + scope: functionApp + properties: { + workspaceId: workspace.id + logs: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +resource scmCredentials 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = { + parent: functionApp + name: 'scm' + properties: { + allow: false + } +} + +resource ftpCredentials 'Microsoft.Web/sites/basicPublishingCredentialsPolicies@2024-04-01' = { + parent: functionApp + name: 'ftp' + properties: { + allow: false + } +} + +output functionAppName string = functionApp.name +output storageAccountName string = storage.name +output keyVaultName string = vault.name +output outboundPrincipalId string = outboundIdentity.properties.principalId +output endpointUrl string = 'https://${functionApp.properties.defaultHostName}/api/SendOtp' +output identifierUri string = identifierUri +output packageContainerUrl string = '${storage.properties.primaryEndpoints.blob}${packages.name}/' diff --git a/setup/packages/catalog.json b/setup/packages/catalog.json new file mode 100644 index 0000000..5461a25 --- /dev/null +++ b/setup/packages/catalog.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "id": "javascript", + "displayName": "JavaScript", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-javascript.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "ready" + }, + { + "id": "dotnet", + "displayName": ".NET", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-dotnet-source.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "dotnet-publish" + }, + { + "id": "python", + "displayName": "Python", + "url": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/epp-python-source.zip", + "checksumsUrl": "https://github.com/siyixian/ExternalPhoneProvider-AzureFunction-Sample/releases/download/epp-provider-auth-preview-20260915/SHA256SUMS.txt", + "buildStrategy": "remote-build" + } + ] +} diff --git a/setup/providers/catalog.json b/setup/providers/catalog.json new file mode 100644 index 0000000..0521434 --- /dev/null +++ b/setup/providers/catalog.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "providers": [ + { + "id": "telesign", + "displayName": "Telesign", + "file": "telesign.json" + }, + { + "id": "soprano", + "displayName": "Soprano", + "file": "soprano.json" + } + ] +} diff --git a/setup/providers/soprano.json b/setup/providers/soprano.json new file mode 100644 index 0000000..4975656 --- /dev/null +++ b/setup/providers/soprano.json @@ -0,0 +1,46 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": true, + "providerName": "Soprano", + "authentication": { + "mode": "oauth", + "tenantId": "00000000-0000-0000-0000-000000000000" + }, + "routes": { + "sms": { + "global": { + "endpoint": "https://soprano-global.example.invalid/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://soprano-eu.example.invalid/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + }, + "voice": { + "global": { + "endpoint": "https://soprano-global.example.invalid/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://soprano-eu.example.invalid/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "scope": "api://00000000-0000-0000-0000-000000000000/.default", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + } + }, + "note": "All Soprano tenant, endpoint, application ID, scope, and timing values are explicit test values until Soprano supplies the production profile." + } +} diff --git a/setup/providers/telesign.json b/setup/providers/telesign.json new file mode 100644 index 0000000..cd4e143 --- /dev/null +++ b/setup/providers/telesign.json @@ -0,0 +1,43 @@ +{ + "deployment": { + "enabled": true, + "testConfiguration": true, + "providerName": "Telesign", + "authentication": { + "mode": "apiKey", + "keyVaultSecretName": "telesign-api-key", + "identityKeyVaultSecretName": "telesign-customer-id" + }, + "routes": { + "sms": { + "global": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/sms", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + }, + "voice": { + "global": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + }, + "eu": { + "endpoint": "https://rest-ww.telesign.com/integration/microsoft-cyot/voice", + "appId": "00000000-0000-0000-0000-000000000000", + "timeoutMilliseconds": 1500, + "retryIntervalSeconds": 30 + } + } + }, + "note": "The supplied global SMS and voice URLs are preserved. EU URLs and application IDs are explicit test values until Telesign provides them." + } +} diff --git a/setup/support/Epp.Packages.ps1 b/setup/support/Epp.Packages.ps1 new file mode 100644 index 0000000..c4fc307 --- /dev/null +++ b/setup/support/Epp.Packages.ps1 @@ -0,0 +1,156 @@ +function Get-EppLanguage { + param([string] $AssetDirectory, [string] $Language, [string] $SourceRepository, [switch] $NonInteractive) + + $catalog = Read-EppJson (Join-Path $AssetDirectory 'packages/catalog.json') + if ($catalog['schemaVersion'] -ne 1 -or -not $catalog['packages']) { throw 'Unsupported or empty language package catalog.' } + $strategies = @{ javascript = 'ready'; dotnet = 'dotnet-publish'; python = 'remote-build' } + $seen = @{} + $entries = @($catalog['packages']) + foreach ($entry in $entries) { + if ($entry -isnot [Collections.IDictionary] -or -not $strategies.ContainsKey([string]$entry['id']) -or + $seen.ContainsKey($entry['id']) -or $entry['buildStrategy'] -cne $strategies[$entry['id']] -or + -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]') { + throw 'Language catalog contains an invalid, unsupported, or duplicate entry.' + } + $seen[$entry['id']] = $true + $null = Read-EppInput -Name PackageUrl -Value $entry['url'] -Kind PackageUrl -SourceRepository $SourceRepository -NonInteractive + $url = [Uri]$entry['url'] + if ($url.Segments[-1] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$' -or + $entry['checksumsUrl'] -cne ($entry['url'].Substring(0, $entry['url'].LastIndexOf('/') + 1) + 'SHA256SUMS.txt')) { + throw 'Each package needs an unambiguous ZIP filename and SHA256SUMS.txt in the same GitHub release.' + } + } + $entry = Select-EppOption -Entries $entries -Name Language -Value $Language -NonInteractive:$NonInteractive + return [pscustomobject]@{ + Id = $entry['id']; DisplayName = $entry['displayName']; Url = $entry['url']; ChecksumsUrl = $entry['checksumsUrl'] + BuildStrategy = $entry['buildStrategy'] + } +} + +function Get-EppPublishedChecksum { + param([string] $Text, [string] $FileName) + + $matches = @() + foreach ($line in ($Text.TrimStart([char]0xfeff) -split '\r?\n')) { + $match = [regex]::Match($line, '^([0-9a-fA-F]{64})[ \t]+\*?(.+?)[ \t]*$') + if ($match.Success -and $match.Groups[2].Value -ceq $FileName) { + $matches += $match.Groups[1].Value.ToLowerInvariant() + } + } + if ($matches.Count -ne 1) { throw "The release checksum file must contain exactly one SHA-256 entry for '$FileName'." } + return $matches[0] +} + +function Assert-EppArchive { + param( + [string] $Path, + [ValidateSet('javascript', 'dotnet', 'python')][string] $Language, + [ValidateSet('source', 'ready')][string] $Kind + ) + + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $names = @($archive.Entries | ForEach-Object FullName) + $required = @('host.json') + switch ($Language) { + 'javascript' { $required += 'package.json' } + 'dotnet' { + if ($Kind -eq 'source') { $required += 'dotnet.csproj' } + else { $required += @('worker.config.json', 'functions.metadata') } + } + 'python' { + $required += @('function_app.py', 'requirements.txt') + if ($Kind -eq 'ready') { $required += '.python_packages/lib/site-packages/azure/functions/__init__.py' } + } + } + foreach ($file in $required) { + if (@($names | Where-Object { $_ -ceq $file }).Count -ne 1) { + throw "The $Language $Kind ZIP must contain exactly one '$file' at its required deployment path." + } + } + if ($Language -eq 'dotnet' -and $Kind -eq 'ready' -and -not @($names | Where-Object { $_ -cmatch '^[^/]+\.dll$' }).Count) { + throw 'The .NET publish output contains no application assemblies.' + } + if (@($names | Where-Object { $_ -match '\\|(^|/)\.\.?(/|$)|^/|^[a-zA-Z]:' }).Count) { + throw 'The Function ZIP contains an absolute or traversing archive path.' + } + foreach ($entry in $archive.Entries) { + if ($entry.FullName -match '(?i)(^|/)(local\.settings[^/]*\.json|\.env(?:\.[^/]*)?|[^/]+\.(pfx|p12|pem|key))$') { + # Python's certifi dependency ships public CA roots, not an application private key. + if ($Language -eq 'python' -and $Kind -eq 'ready' -and + $entry.FullName -ceq '.python_packages/lib/site-packages/certifi/cacert.pem') { + $reader = [IO.StreamReader]::new($entry.Open()) + try { + if ($reader.ReadToEnd() -match '-----BEGIN [^-]*PRIVATE KEY-----') { throw 'The CA bundle contains a private key.' } + } + finally { $reader.Dispose() } + continue + } + throw 'The Function ZIP contains local settings or key material. Do not deploy this package.' + } + } + } + finally { $archive.Dispose() } +} + +function Invoke-EppDotNet { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $PSNativeCommandUseErrorActionPreference = $false + $output = & dotnet @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { throw "dotnet $($Arguments[0]) failed (exit $LASTEXITCODE):`n$($output -join "`n")" } + return $output -join "`n" +} + +function Build-EppDotNetPackage { + param([string] $SourcePath, [string] $Directory) + + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw 'The .NET language needs the .NET 8 SDK on this computer. Install it once; setup performs the build automatically.' + } + $versions = @((Invoke-EppDotNet --list-sdks) -split '\r?\n' | ForEach-Object { + if ($_ -match '^(8\.\d+\.\d+)\s') { [Version]$Matches[1] } + } | Sort-Object -Descending) + if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying the .NET language. No Azure resources were changed.' } + $sourceDirectory = Join-Path $Directory 'dotnet-source' + $publishDirectory = Join-Path $Directory 'dotnet-publish' + [IO.Compression.ZipFile]::ExtractToDirectory($SourcePath, $sourceDirectory) + @{ sdk = @{ version = $versions[0].ToString(); rollForward = 'latestPatch' } } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $sourceDirectory 'global.json') -Encoding utf8NoBOM + Write-Host 'Building .NET 8 for Linux automatically...' -ForegroundColor Cyan + Push-Location -LiteralPath $sourceDirectory + try { + Invoke-EppDotNet -Arguments @('publish', 'dotnet.csproj', '--configuration', 'Release', '--runtime', 'linux-x64', + '--self-contained', 'false', '-p:UseAppHost=false', '--output', $publishDirectory, '--nologo') | Out-Null + } + finally { Pop-Location } + $path = Join-Path $Directory 'dotnet-ready.zip' + [IO.Compression.ZipFile]::CreateFromDirectory($publishDirectory, $path) + Assert-EppArchive -Path $path -Language dotnet -Kind ready + return $path +} + +function Get-EppPackage { + param($Selection, [string] $Directory) + + Write-Host "Downloading $($Selection.DisplayName) and verifying its published checksum automatically..." -ForegroundColor Cyan + $checksumPath = Join-Path $Directory "$($Selection.Id)-SHA256SUMS.txt" + Invoke-WebRequest -Uri $Selection.ChecksumsUrl -OutFile $checksumPath -TimeoutSec 60 + if ((Get-Item -LiteralPath $checksumPath).Length -gt 1MB) { throw 'The release checksum file is unexpectedly large.' } + $fileName = ([Uri]$Selection.Url).Segments[-1] + $expected = Get-EppPublishedChecksum -Text (Get-Content -LiteralPath $checksumPath -Raw -Encoding utf8) -FileName $fileName + $sourcePath = Join-Path $Directory $fileName + Invoke-WebRequest -Uri $Selection.Url -OutFile $sourcePath -TimeoutSec 300 + if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -ine $expected) { + throw 'The downloaded Function ZIP does not match its published SHA-256. No Azure resources were changed.' + } + $kind = if ($Selection.BuildStrategy -eq 'ready') { 'ready' } else { 'source' } + Assert-EppArchive -Path $sourcePath -Language $Selection.Id -Kind $kind + $path = if ($Selection.BuildStrategy -eq 'dotnet-publish') { Build-EppDotNetPackage -SourcePath $sourcePath -Directory $Directory } else { $sourcePath } + return [pscustomobject]@{ + Path = $path + SourceSha256 = $expected + Sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + RequiresRemoteBuild = $Selection.BuildStrategy -eq 'remote-build' + } +} diff --git a/setup/support/Epp.Setup.psm1 b/setup/support/Epp.Setup.psm1 new file mode 100644 index 0000000..69fa5c7 --- /dev/null +++ b/setup/support/Epp.Setup.psm1 @@ -0,0 +1,1032 @@ +#Requires -Version 7.0 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$script:MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13' +. (Join-Path $PSScriptRoot 'Epp.Packages.ps1') + +function Read-EppJson { + param([string] $Path) + + $value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop + if ($value -isnot [Collections.IDictionary]) { throw "Expected a JSON object in '$Path'." } + return $value +} + +function ConvertTo-EppGuid { + param([string] $Value, [switch] $AllowZero) + + $guid = [Guid]::Empty + if (-not [Guid]::TryParse($Value, [ref] $guid) -or (-not $AllowZero -and $guid -eq [Guid]::Empty)) { + throw 'Use a nonempty GUID, not an application name or an all-zero placeholder.' + } + return $guid.ToString('D') +} + +function Assert-EppHttpsUrl { + param([string] $Value, [switch] $AllowTestHost) + + $uri = $null + if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref] $uri) -or + $uri.Scheme -ne 'https' -or $uri.Port -ne 443 -or $uri.IsLoopback -or + $uri.HostNameType -ne [UriHostNameType]::Dns -or $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + $uri.Host -notmatch '\.' -or + (-not $AllowTestHost -and $uri.Host -match '(?i)((^|\.)example\.(com|net|org)$|\.(invalid|test|example)$)')) { + throw 'Use a public HTTPS hostname on port 443, without credentials, a query string, or placeholders.' + } +} + +function Select-EppOption { + param([object[]] $Entries, [string] $Name, [string] $Value, [switch] $NonInteractive) + + $ids = @($Entries | ForEach-Object { $_['id'] }) + if ($Value) { + $selected = $Entries | Where-Object { $_['id'] -ieq $Value -or $_['displayName'] -ieq $Value } | Select-Object -First 1 + if (-not $selected) { throw "Unknown $Name '$Value'. Choose: $($ids -join ', ')." } + return $selected + } + if ($NonInteractive) { throw "-$Name is required. Choose: $($ids -join ', ')." } + Write-Host "`nChoose your $($Name.ToLowerInvariant()):" -ForegroundColor Cyan + for ($index = 0; $index -lt $Entries.Count; $index++) { Write-Host " [$($index + 1)] $($Entries[$index]['displayName'])" } + while ($true) { + $answer = ([string](Read-Host "$Name number or name")).Trim() + $number = 0 + if ([int]::TryParse($answer, [ref] $number) -and $number -ge 1 -and $number -le $Entries.Count) { return $Entries[$number - 1] } + $selected = $Entries | Where-Object { $_['id'] -ieq $answer -or $_['displayName'] -ieq $answer } | Select-Object -First 1 + if ($selected) { return $selected } + Write-Warning "Choose one of the listed $($Name.ToLowerInvariant()) options." + } +} + +function Read-EppInput { + param( + [string] $Name, [string] $Value, [string] $Hint, + [ValidateSet('Text', 'Guid', 'Location', 'Prefix', 'PackageUrl', 'Hash')] + [string] $Kind = 'Text', + [switch] $NonInteractive, + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample' + ) + + $supplied = -not [string]::IsNullOrWhiteSpace($Value) + while ($true) { + if (-not $supplied) { + if ($NonInteractive) { throw "-$Name is required in noninteractive mode." } + $Value = [string](Read-Host "$Name - $Hint") + } + $Value = $Value.Trim() + try { + if (-not $Value -or $Value -match '[\x00-\x1f<>]') { throw 'A nonempty value without placeholders is required.' } + switch ($Kind) { + 'Guid' { $Value = ConvertTo-EppGuid $Value } + 'Location' { + if ($Value -cnotmatch '^[a-z][a-z0-9]+$') { throw 'Use an Azure region name such as westus2.' } + } + 'Prefix' { + if ($Value -cnotmatch '^[a-z][a-z0-9]{1,7}$') { + throw 'Use 2-8 lowercase letters or digits, starting with a letter (for example contoso).' + } + } + 'PackageUrl' { + Assert-EppHttpsUrl $Value + $repositories = @('Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample', $SourceRepository) + $allowed = @($repositories | Where-Object { + $Value -cmatch ('^https://github\.com/' + [regex]::Escape($_) + '/releases/download/[^/]+/[^/]+\.zip$') + }) + if (-not $allowed.Count) { + throw 'Use a versioned ZIP release URL from the selected source repository or Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample.' + } + } + 'Hash' { + if ($Value -notmatch '^[0-9a-fA-F]{64}$') { throw 'Use the package SHA-256 from its release checksums.' } + $Value = $Value.ToLowerInvariant() + } + } + return $Value + } + catch { + if ($supplied) { throw "Invalid -${Name}: $($_.Exception.Message)" } + Write-Warning "$Name : $($_.Exception.Message)" + } + } +} + +function Get-EppProvider { + param( + [string] $AssetDirectory, [string] $SourceBaseUri, [string] $Provider, [string] $Channel, + [string] $EndpointRegion, [switch] $NonInteractive, + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $SourceRepository = 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample' + ) + + $sourcePattern = '^https://raw\.githubusercontent\.com/' + [regex]::Escape($SourceRepository) + '/[0-9a-fA-F]{40}/setup$' + if ($SourceBaseUri -cnotmatch $sourcePattern) { + throw 'Provider files must come from the same commit-pinned selected repository as the deployment tools.' + } + $catalog = Read-EppJson (Join-Path $AssetDirectory 'providers/catalog.json') + if ($catalog['schemaVersion'] -ne 1 -or -not $catalog['providers']) { throw 'Unsupported or empty provider catalog.' } + $entries = @($catalog['providers']) + $ids = @{} + foreach ($entry in $entries) { + if ($entry -isnot [Collections.IDictionary] -or $entry['id'] -cnotmatch '^[a-z][a-z0-9-]{1,31}$' -or + $entry['file'] -cnotmatch '^[a-z][a-z0-9-]{1,31}\.json$' -or + -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]' -or $ids.ContainsKey($entry['id'])) { + throw 'Provider catalog contains an invalid or duplicate entry.' + } + $ids[$entry['id']] = $true + } + $selected = Select-EppOption -Entries $entries -Name Provider -Value $Provider -NonInteractive:$NonInteractive + + $path = Join-Path $AssetDirectory "providers/$($selected['file'])" + Invoke-WebRequest -Uri "$SourceBaseUri/providers/$($selected['file'])" -OutFile $path -TimeoutSec 60 -MaximumRedirection 0 + $profile = Read-EppJson $path + return ConvertTo-EppProviderSettings -Profile $profile -Id $selected['id'] -DisplayName $selected['displayName'] ` + -Channel $Channel -EndpointRegion $EndpointRegion -NonInteractive:$NonInteractive +} + +function ConvertTo-EppProviderSettings { + param( + [Collections.IDictionary] $Profile, [string] $Id, [string] $DisplayName, + [string] $Channel, [string] $EndpointRegion, [switch] $NonInteractive + ) + + $issues = [Collections.Generic.List[string]]::new() + $deployment = $Profile['deployment'] + if ($deployment -isnot [Collections.IDictionary]) { throw "Provider '$DisplayName' has no deployment configuration." } + $testConfiguration = $deployment['testConfiguration'] -eq $true + if ($deployment.Contains('testConfiguration') -and $deployment['testConfiguration'] -isnot [bool]) { + $issues.Add('deployment.testConfiguration must be a JSON Boolean') + } + if ($deployment['enabled'] -isnot [bool] -or -not $deployment['enabled']) { + $issues.Add('the provider owner has not enabled this profile') + } + if ($deployment['providerName'] -ine $Id) { $issues.Add('deployment.providerName must match the catalog ID or display name') } + + $authentication = $deployment['authentication'] + if ($authentication -isnot [Collections.IDictionary] -or $authentication['mode'] -notin @('apiKey', 'oauth')) { + $issues.Add('deployment.authentication.mode must be apiKey or oauth') + } + $authenticationMode = if ($authentication -is [Collections.IDictionary]) { [string]$authentication['mode'] } else { '' } + if ($authenticationMode -eq 'apiKey') { + foreach ($name in @('keyVaultSecretName', 'identityKeyVaultSecretName')) { + if ($authentication[$name] -cnotmatch '^[a-z0-9][a-z0-9-]{1,126}$') { + $issues.Add("deployment.authentication.$name must be a Key Vault secret name") + } + } + } + elseif ($authenticationMode -eq 'oauth') { + try { $null = ConvertTo-EppGuid $authentication['tenantId'] -AllowZero:$testConfiguration } + catch { $issues.Add('deployment.authentication.tenantId must identify the provider OAuth tenant') } + } + + $routes = $deployment['routes'] + if ($routes -isnot [Collections.IDictionary]) { throw "Provider '$DisplayName' is missing deployment.routes." } + foreach ($channelId in @('sms', 'voice')) { + if ($routes[$channelId] -isnot [Collections.IDictionary]) { + $issues.Add("deployment.routes.$channelId is missing") + continue + } + foreach ($regionId in @('global', 'eu')) { + $route = $routes[$channelId][$regionId] + if ($route -isnot [Collections.IDictionary]) { + $issues.Add("deployment.routes.$channelId.$regionId is missing") + continue + } + try { Assert-EppHttpsUrl $route['endpoint'] -AllowTestHost:$testConfiguration } + catch { $issues.Add("deployment.routes.$channelId.$regionId.endpoint must be a public HTTPS endpoint") } + $timeout = $route['timeoutMilliseconds'] + $retry = $route['retryIntervalSeconds'] + if (($timeout -isnot [long] -and $timeout -isnot [int]) -or $timeout -lt 1 -or $timeout -gt 2500) { + $issues.Add("deployment.routes.$channelId.$regionId.timeoutMilliseconds must be an integer from 1 to 2500") + } + if (($retry -isnot [long] -and $retry -isnot [int]) -or $retry -lt 0 -or $retry -gt 2147483) { + $issues.Add("deployment.routes.$channelId.$regionId.retryIntervalSeconds must be a nonnegative integer fitting Int32 milliseconds") + } + if ($authenticationMode -eq 'oauth') { + try { $null = ConvertTo-EppGuid $route['appId'] -AllowZero:$testConfiguration } + catch { $issues.Add("deployment.routes.$channelId.$regionId.appId must identify the provider API application") } + $scope = [string]$route['scope'] + $resource = $scope -replace '/\.default$', '' + $resourceUri = $null + $resourceGuid = [Guid]::Empty + $validResource = ([Guid]::TryParse($resource, [ref] $resourceGuid) -and ($testConfiguration -or $resourceGuid -ne [Guid]::Empty)) -or + ([Uri]::TryCreate($resource, [UriKind]::Absolute, [ref] $resourceUri) -and + $resourceUri.Scheme -in @('api', 'https') -and $resourceUri.Host -and + -not $resourceUri.UserInfo -and -not $resourceUri.Query -and -not $resourceUri.Fragment) + if (-not $validResource -or $scope -notmatch '/\.default$' -or $scope -match '[\s<>]') { + $issues.Add("deployment.routes.$channelId.$regionId.scope must be the provider API resource followed by /.default") + } + } + } + } + if ($issues.Count) { + throw "Provider '$DisplayName' is not deployment-ready:`n - $($issues -join "`n - ")`nAsk the provider owner to complete its GitHub JSON. No Azure resources were changed." + } + + $channelEntry = Select-EppOption -Entries @( + @{ id = 'sms'; displayName = 'SMS' } + @{ id = 'voice'; displayName = 'Voice' } + ) -Name Channel -Value $Channel -NonInteractive:$NonInteractive + $regionEntry = Select-EppOption -Entries @( + @{ id = 'global'; displayName = 'Global endpoint' } + @{ id = 'eu'; displayName = 'EU endpoint' } + ) -Name EndpointRegion -Value $EndpointRegion -NonInteractive:$NonInteractive + $selectedRoute = $routes[$channelEntry['id']][$regionEntry['id']] + $settings = @{ + EPP_PROVIDER_NAME = $Id + EPP_PROVIDER_ENDPOINT = [string]$selectedRoute['endpoint'] + EPP_PROVIDER_CHANNEL = [string]$channelEntry['id'] + EPP_PROVIDER_ENDPOINT_REGION = [string]$regionEntry['id'] + EPP_PROVIDER_TIMEOUT_MS = [string]$selectedRoute['timeoutMilliseconds'] + EPP_PROVIDER_RETRY_INTERVAL_MS = [string]([long]$selectedRoute['retryIntervalSeconds'] * 1000) + EPP_PROVIDER_AUTH_MODE = $authenticationMode + EPP_PROVIDER_TEST_CONFIGURATION = $testConfiguration.ToString().ToLowerInvariant() + } + if ($authenticationMode -eq 'oauth') { + $settings.EPP_PROVIDER_TENANT_ID = ConvertTo-EppGuid $authentication['tenantId'] -AllowZero:$testConfiguration + $settings.EPP_PROVIDER_SCOPE = [string]$selectedRoute['scope'] + $settings.EPP_PROVIDER_APP_ID = [string]$selectedRoute['appId'] + } + return [pscustomobject]@{ + Id = $Id + DisplayName = $DisplayName + Manifest = $Profile + IsTestConfiguration = $testConfiguration + Channel = [string]$channelEntry['id'] + EndpointRegion = [string]$regionEntry['id'] + AuthenticationMode = $authenticationMode + Settings = $settings + } +} + +function Get-EppResourceNames { + param([string] $SubscriptionId, [string] $ApplicationId, [string] $ResourcePrefix) + + if ($ResourcePrefix -cnotmatch '^[a-z][a-z0-9]{1,7}$') { throw 'ResourcePrefix must be 2-8 lowercase letters/digits, starting with a letter.' } + $seed = "$(ConvertTo-EppGuid $SubscriptionId)|$(ConvertTo-EppGuid $ApplicationId)|$ResourcePrefix" + $sha = [Security.Cryptography.SHA256]::Create() + try { $suffix = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed))) -replace '-', '').Substring(0, 8).ToLowerInvariant() } + finally { $sha.Dispose() } + return [ordered]@{ + resourceGroup = "$ResourcePrefix-epp-rg-$suffix" + functionApp = "$ResourcePrefix-epp-func-$suffix" + storageAccount = "${ResourcePrefix}eppsa$suffix" + keyVault = "$ResourcePrefix-epp-kv-$suffix" + hostingPlan = "$ResourcePrefix-epp-plan-$suffix" + logAnalytics = "$ResourcePrefix-epp-logs-$suffix" + applicationInsights = "$ResourcePrefix-epp-insights-$suffix" + outboundIdentity = "$ResourcePrefix-epp-outbound-$suffix" + } +} + +function Invoke-EppAz { + param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) + + $PSNativeCommandUseErrorActionPreference = $false + $errorPath = Join-Path ([IO.Path]::GetTempPath()) "epp-az-$([Guid]::NewGuid().ToString('N')).stderr" + try { + $output = & az @Arguments --only-show-errors 2> $errorPath + $exitCode = $LASTEXITCODE + $errorText = if (Test-Path -LiteralPath $errorPath) { [string](Get-Content -LiteralPath $errorPath -Raw) } else { '' } + $message = (($output -join "`n") + "`n" + $errorText) -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + $message = $message -replace '(?i)(Bearer\s+)[^\s,;]+', '$1[REDACTED]' + if ($exitCode -ne 0) { + throw "Azure CLI operation '$($Arguments[0]) $($Arguments[1])' failed (exit $exitCode): $message" + } + if (-not [string]::IsNullOrWhiteSpace($errorText)) { + $warning = $errorText -replace '(?i)([?&](?:sig|token|code|client_secret|password)=)[^&\s]+', '$1[REDACTED]' + Write-Warning ($warning -replace '(?i)(Bearer\s+)[^\s,;]+', '$1[REDACTED]') + } + return $output -join "`n" + } + finally { if (Test-Path -LiteralPath $errorPath) { Remove-Item -LiteralPath $errorPath -Force } } +} + +function Invoke-EppDataOperation { + param([scriptblock] $Operation) + + for ($attempt = 1; $attempt -le 12; $attempt++) { + try { return & $Operation } + catch { + if ($attempt -eq 12 -or $_.Exception.Message -notmatch 'ForbiddenByRbac|AuthorizationPermissionMismatch|Caller is not authorized to perform action on resource') { throw } + Write-Warning "Waiting for the new data-plane role assignment ($attempt/12)." + Start-Sleep -Seconds 10 + } + } +} + +function Import-EppGraphModules { + # The SDK and its sign-in context belong to the session, not this temporary helper module. + Import-Module Microsoft.Graph.Authentication -Global -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -Global -ErrorAction Stop +} + +function Get-EppInitialGraphContext { + try { return Get-MgContext -ErrorAction Stop } + catch { + if ($_.Exception.GetBaseException().Message -cne 'SessionNotInitialized') { throw } + } + + # Graph's failed OnRemove hook can reset its static session while leaving the module loaded. + $authentication = @(Get-Module -Name Microsoft.Graph.Authentication) + if ($authentication.Count -ne 1) { + throw 'The Graph SDK session is uninitialized and its loaded Authentication version is ambiguous. Run setup in a fresh PowerShell process with pwsh -NoProfile.' + } + Write-Warning 'An earlier module removal reset the Graph SDK session. Reloading its existing Authentication version once; sign-in may be required.' + Import-Module Microsoft.Graph.Authentication -RequiredVersion $authentication[0].Version -Global -Force -ErrorAction Stop + try { return Get-MgContext -ErrorAction Stop } + catch { + if ($_.Exception.GetBaseException().Message -cne 'SessionNotInitialized') { throw } + throw 'The Graph SDK session could not be reinitialized. Run setup in a fresh PowerShell process with pwsh -NoProfile; no Azure resources were changed.' + } +} + +function Get-EppResourceProviderRequirements { + @( + @{ Namespace = 'Microsoft.Web'; Type = 'sites' } + @{ Namespace = 'Microsoft.Storage'; Type = 'storageAccounts' } + @{ Namespace = 'Microsoft.KeyVault'; Type = 'vaults' } + @{ Namespace = 'Microsoft.OperationalInsights'; Type = 'workspaces' } + @{ Namespace = 'Microsoft.Insights'; Type = 'components' } + @{ Namespace = 'Microsoft.ManagedIdentity'; Type = 'userAssignedIdentities' } + ) +} + +function Get-EppResourceProviders { + param([string] $SubscriptionId) + + foreach ($provider in Get-EppResourceProviderRequirements) { + $registration = Invoke-EppAz provider show --namespace $provider.Namespace --subscription $SubscriptionId --output json | + ConvertFrom-Json + if (-not $registration -or -not $registration.PSObject.Properties['registrationState'] -or + $registration.registrationState -notin @('Registered', 'Registering', 'NotRegistered', 'Unregistering')) { + throw "Azure returned an unsupported registration state for '$($provider.Namespace)'." + } + if ($registration.registrationState -eq 'Unregistering') { + throw "Resource provider '$($provider.Namespace)' is being unregistered. Let that operation finish before rerunning setup; it will not be reversed automatically." + } + $locations = @() + if ($registration.PSObject.Properties['resourceTypes'] -and $registration.resourceTypes) { + $locations = @($registration.resourceTypes | Where-Object { $_ -and $_.resourceType -eq $provider.Type } | ForEach-Object locations) + } + [pscustomobject]@{ + Namespace = $provider.Namespace; Type = $provider.Type + RegistrationState = $registration.registrationState; Locations = $locations + } + } +} + +function Test-EppProviderLocation { + param($Provider, [string] $Location) + + return @($Provider.Locations | Where-Object { ($_ -replace '[^a-zA-Z0-9]', '') -ieq $Location }).Count -gt 0 +} + +function Assert-EppProviderLocations { + param([object[]] $Providers, [string] $Location) + + foreach ($provider in $Providers) { + if ($provider.RegistrationState -eq 'Registered' -and -not (Test-EppProviderLocation $provider $Location)) { + throw "'$($provider.Namespace)/$($provider.Type)' is unavailable in '$Location'. Choose another location." + } + } +} + +function Assert-EppPremiumLocation { + param([hashtable] $Inputs) + + $endpoint = "https://management.azure.com/subscriptions/$($Inputs.SubscriptionId)/providers/Microsoft.Web/geoRegions" + $required = @{ 'api-version' = '2024-04-01'; sku = 'ElasticPremium'; linuxWorkersEnabled = 'true' } + $parameters = @{} + $required + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $queryPath = Join-Path ([IO.Path]::GetTempPath()) "epp-regions-$([Guid]::NewGuid().ToString('N')).json" + try { + for ($pageNumber = 1; $pageNumber -le 20; $pageNumber++) { + # A query file keeps ampersands and continuation tokens away from Windows az.cmd parsing. + $parameters | ConvertTo-Json | Set-Content -LiteralPath $queryPath -Encoding utf8NoBOM + $page = Invoke-EppAz rest --method get --url $endpoint --url-parameters "@$queryPath" ` + --subscription $Inputs.SubscriptionId --output json | ConvertFrom-Json -AsHashtable + if ($page -isnot [Collections.IDictionary] -or $page['value'] -isnot [Array]) { + throw 'Azure returned an invalid Elastic Premium region response.' + } + if (@($page['value'] | Where-Object { + $_ -and $_['name'] -is [string] -and ($_['name'] -replace '[^a-zA-Z0-9]', '') -ieq $Inputs.Location + }).Count) { return } + if (-not $page['nextLink']) { throw "Linux Premium EP1 is unavailable in '$($Inputs.Location)'." } + $next = $null + if (-not [Uri]::TryCreate([string]$page['nextLink'], [UriKind]::Absolute, [ref]$next) -or + $next.Scheme -ne 'https' -or $next.Port -ne 443 -or $next.UserInfo -or $next.Fragment -or + $next.GetLeftPart([UriPartial]::Path) -ine $endpoint -or -not $seen.Add($next.AbsoluteUri)) { + throw 'Azure returned an invalid or repeated Elastic Premium region continuation link.' + } + $parameters = @{} + $required + foreach ($pair in $next.Query.TrimStart('?').Split('&', [StringSplitOptions]::RemoveEmptyEntries)) { + $parts = $pair.Split('=', 2) + if ($parts.Count -ne 2) { throw 'Azure returned an invalid region continuation parameter.' } + $name = [Uri]::UnescapeDataString($parts[0].Replace('+', ' ')) + $value = [Uri]::UnescapeDataString($parts[1].Replace('+', ' ')) + if ($required.ContainsKey($name) -and $required[$name] -cne $value) { + throw 'Azure region pagination changed the approved Elastic Premium/Linux filter.' + } + $parameters[$name] = $value + } + } + throw 'Azure region pagination exceeded the supported page limit.' + } + finally { + if (Test-Path -LiteralPath $queryPath) { Remove-Item -LiteralPath $queryPath -Force } + } +} + +function Test-EppRegistrationDelay { + param([string] $Message) + + if ($Message -notmatch '\b(MissingSubscriptionRegistration|SubscriptionNotRegistered)\b') { return $false } + foreach ($provider in Get-EppResourceProviderRequirements) { + if ($Message -match ('(?