diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b6f0a9..bc5c02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,15 @@ on: pull_request: jobs: + cyot-setup: + name: CYOT setup (PowerShell) + runs-on: windows-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Check standalone setup scripts (offline) + shell: pwsh + run: .\tests\setup\Test-CyotScripts.ps1 + javascript: name: JavaScript (Node.js) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 23efa09..a284162 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,15 @@ node_modules/ # Local helper scripts scripts/ +# Generated CYOT setup files (the standalone setup scripts remain tracked) +/setup/cyot/*.backup-*.ps1 +/setup/cyot/*.cer +/setup/cyot/*.crt +/setup/cyot/*.zip +/setup/cyot/cyot-policy-before-*.json +/setup/cyot/arm/*.local.json +/setup/cyot/arm/parameters.json + # Python .venv/ venv/ diff --git a/README.md b/README.md index 3a5cfbc..be39d8f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ 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. +CYOT registration, provisioning and policy scripts are in **[setup/cyot/](setup/cyot/)**. +Step 2 is one entry point that runs both JSON templates in its companion `arm` folder. +Steps 1 and 3 remain independently runnable; no shared helper script is required. Read the +[compatibility limitations](setup/cyot/README.md#compatibility-with-this-sample) before provisioning: +the imported workflow is not yet a compatible end-to-end deployment path for these implementations. + ## The design in one line SAS → Easy Auth → anonymous HTTP handler (`POST /api/SendOtp`, validate envelope + decrypt JWE) → @@ -116,6 +122,7 @@ authentication; [separate deployed security checks](docs/ONBOARDING.md#4-package - **[docs/ONBOARDING.md](docs/ONBOARDING.md)** — customer setup / run / secure / deploy guide. - **[docs/CONTRACT.md](docs/CONTRACT.md)** — the language-agnostic contract every implementation follows. +- **[setup/cyot/README.md](setup/cyot/README.md)** — standalone PowerShell stages, prerequisites and compatibility limits. ## Contributing a language or provider diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bbb14e1..89079d6 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -19,8 +19,28 @@ account and environment. Individual API contracts stay in the adapters. ### Setup script 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 three [CYOT setup scripts](../setup/cyot/README.md) are separate stages for application registration, +resource provisioning and policy activation. Step 2 is a small launcher for its two companion ARM JSON +templates. The second template runs certificate/Graph/secret preparation in Azure under a separately +pre-authorized deployment identity. Copy its `arm` folder with the script; you do not run the +templates as separate manual steps. Steps 1 and 3 remain single-file scripts. +Pass the same application client ID between stages. + +Supply the same customer `-TenantId` in each stage. Step 2 signs Azure CLI into that tenant and +validates the explicitly supplied `-SubscriptionId`. Resource names, the application ID and provider +settings are supplied through local parameter files. It never guesses from the previous CLI default. +See [running Step 2](../setup/cyot/README.md#run-step-2) and its deployment-identity prerequisites. + +**Step 2 is not yet compatible with this sample unchanged.** It provisions outbound Entra OAuth +settings and sets the application's `tokenEncryptionKeyId`, whereas the implementations here use +provider API keys and require signed, unencrypted bearer tokens at Easy Auth. Importing the scripts +does not add outbound OAuth or access-token decryption to any runtime. Review the +[compatibility limits](../setup/cyot/README.md#compatibility-with-this-sample) before provisioning. +Step 3 separately refuses a policy write unless the live Graph schema exposes its exact CYOT contract. + +Step 2 creates the encryption-key secret, not the selected provider's API credentials. For this +sample's API-key configuration, the following are still required; they do not resolve the OAuth or +access-token-encryption incompatibilities: 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 @@ -36,7 +56,8 @@ credentials. Before live delivery, complete these steps: 4. Supply any additional options read by the selected adapter. Registering a provider does not make every account option or channel automatically available. -The script already writes the correct `EPP_` names; no variable-prefix translation is required. +Shared settings already use the correct `EPP_` names; no variable-prefix translation is required. +Additional settings do not enable behavior the application does not implement. | Setup value | Current application behavior | |---|---| @@ -48,6 +69,7 @@ The script already writes the correct `EPP_` names; no variable-prefix translati | `EPP_DECRYPTION_KEY_PEM` | PEM or base64 PEM, usually resolved from a Key Vault secret reference. | | `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. | +| `EPP_PROVIDER_AUTH_MODE`, `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, `EPP_OUTBOUND_MI_CLIENT_ID` | Step 2 writes these for outbound OAuth. This sample does not consume them or perform the token exchange; it still requires the adapter's API-key secrets. | **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 @@ -66,8 +88,11 @@ 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 +Step 2 also lacks the required 100-byte UTF-8 endpoint-URL check before deployment. +The Azure-hosted preparation requires an exportable RSA certificate and stores its private key in +Key Vault. ARM what-if/approval precedes each deployment, but does not preview individual +certificate or Graph operations inside deployment scripts. Code publishing and deployed endpoint +verification are separate. 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. diff --git a/setup/cyot/README.md b/setup/cyot/README.md new file mode 100644 index 0000000..d29348c --- /dev/null +++ b/setup/cyot/README.md @@ -0,0 +1,126 @@ +# CYOT setup + +| Stage | Responsibility | +|---|---| +| [Step 1](Step1-Register-CyotApplication.ps1) | Register or reuse the customer's multi-tenant application. Return its client ID for provider onboarding. | +| [Step 2](Step2-Setup-ExternalPhoneProvider.ps1) | A small PowerShell launcher that deploys both ARM JSON files. Certificate, Graph and secret preparation runs in Azure, not on the local machine. | +| [Step 3](Step3-Set-CyotPolicy.ps1) | Separately check the supported Graph policy contract and explicitly activate/update CYOT. Never called by Step 2. | + +**Read [compatibility with this sample](#compatibility-with-this-sample) before deploying the application +code.** These templates preserve the imported provider/federation behavior; they do not implement +outbound OAuth in this repository's runtime. + +## Files + +```text +Step2-Setup-ExternalPhoneProvider.ps1 +arm\ + infrastructure.json + parameters.sample.json + function-config.json + function-config.parameters.sample.json +``` + +Keep the `arm` folder alongside Step 2. No other PowerShell file is needed. +Steps 1 and 3 remain individually copyable. + +## Prerequisites + +- PowerShell 7 and Azure CLI on the machine running Step 2. The local Step 2 launcher no longer needs + Microsoft Graph PowerShell modules or a Windows certificate store. +- An existing resource group and permission to deploy resources, role assignments, Azure Container + Instances and deployment scripts, plus permission to assign the deployment identity. +- A **separate, existing user-assigned preparation identity in the customer tenant**. An Entra + administrator must grant its required Microsoft Graph application permissions and ownership of + the Step 1 application beforehand. Prefer `Application.ReadWrite.OwnedBy` with ownership of the + target application where sufficient. Azure subscription Owner is not Microsoft Graph consent. +- Never attach that preparation identity to the running Function. The outbound Function identity + is a different identity, without application-administration privileges. +- Complete Step 1 and provider onboarding first. Use the same customer tenant, application ID, + resource names, plan and token version in both parameter files. + +The infrastructure template grants the preparation identity Certificates Officer and Secrets Officer +on the selected vault. It does **not** grant Graph permissions. Deployment scripts use temporary +container/storage resources that incur charges until cleaned up. They need network access to Graph +and Key Vault; private-network environments need separate planning. + +## Run Step 2 + +**Run only Step 2.** When both local parameter files are missing, a short inline prompt loop +collects their values and saves them before deploying: + +```powershell +.\setup\cyot\Step2-Setup-ExternalPhoneProvider.ps1 ` + -TenantId '' ` + -SubscriptionId '' ` + -ResourceGroup 'rg-external-phone-provider' +``` + +Step 2 reuses the supplied tenant and asks for the Function name, application ID, preparation +identity, plan/token version, provider metadata and location. Shared answers are written to both +files. Resource names are generated by ARM defaults, not by a PowerShell naming framework. +This does not create or authorize the preparation identity. + +Subsequent runs reuse both existing JSON files without regenerating them. If only one file exists, +Step 2 stops rather than overwriting it. Custom `-InfrastructureParameters` and +`-ConfigurationParameters` paths are also supported; their parent directories must exist. + +For existing deployments or advanced values, edit the local JSON files before running Step 2. +Use the original resource names instead of the new ARM defaults. Put shared name overrides in +both files, and choose token version 2 when the existing app issues v2 access tokens. +There is no automatic overwrite or regeneration. You can also copy and edit the sample JSON files. + +Local parameter copies are ignored by Git. Never put private keys, access tokens or passwords in +the samples. Provider settings in `managedSettings` must be nonsecret; use Key Vault references for +credentials. + +The launcher signs into the explicit tenant, verifies the subscription, then calls: + +1. **[infrastructure.json](arm/infrastructure.json):** storage, hosting, Function, monitoring, + identities, vault, scoped role assignments and Easy Auth. +2. **[function-config.json](arm/function-config.json):** an Azure-hosted preparation script + creates/reuses an exportable RSA certificate in Key Vault, writes its PKCS8 private-key secret, + updates the app's public-key/endpoint metadata and federation, then applies app settings. + +Both deployments are incremental and use ARM what-if confirmation. Failure stops the launcher. +What-if does not preview the individual Graph/data-plane changes inside a deployment script; +review that script and its permissions before approving deployment. + +`setupRevision` can be incremented deliberately to rerun preparation. Valid existing certificates +and matching credentials are reused. An expired certificate requires deliberate renewal. +Outputs contain endpoint/application/key IDs, never private-key or secret values. +An existing private-key secret without its matching managed Key Vault certificate is not replaced: +import the matching certificate as `phone-provider-encryption` or start with a fresh vault. + +**Function code publishing is now separate.** Build/publish the chosen language or compatible +package after configuration is ready. Step 2 no longer accepts `-ZipPath`, `-ZipUrl`, `-CertificatePath` +or `-EndpointUrl`; deployment settings belong in the JSON parameter files. Validate the deployed +endpoint before running Step 3. + +## Existing resources + +Use the original resource names. `infrastructure.json` still supports explicit `existing` snapshots +and role-assignment IDs for adopting earlier deployments, but the launcher no longer discovers or +migrates them automatically. Likewise, `existingAppSettings` preserves only settings you explicitly +provide. Review customized deployments before reuse: ARM incremental mode is not a property merge. +Prefer a dedicated resource group for an initial manual trial. + +There is no resource cleanup, vault purge, recovery or group-wide tagging script. Steps 1 and 3 +are unchanged. + +## Compatibility with this sample + +- The imported setup uses outbound Entra federation. The JavaScript, Python and .NET implementations + here still use provider API keys from Key Vault; these ARM changes do not add token exchange. +- Preparation retains the existing `tokenEncryptionKeyId` nomination. This conflicts with the + current sample's requirement for **unencrypted bearer tokens** at Easy Auth; resolve that + registration behavior before using the sample end to end. +- Easy Auth must remain enabled for the anonymous handlers. Do not use a custom-authentication + template path unless the deployed code implements its own validator. +- Flex Consumption uses managed-identity deployment storage. Premium uses an explicitly created + Azure Files share and a Key Vault reference for its content connection string. Verify the share + and reference are ready before publishing code. +- The sample waits for provider acceptance and uses one decryption key; setup does not add + asynchronous delivery, overlapping-key selection or retry handling. + +See [the runtime contract](../../docs/CONTRACT.md) and [onboarding guide](../../docs/ONBOARDING.md). diff --git a/setup/cyot/Step1-Register-CyotApplication.ps1 b/setup/cyot/Step1-Register-CyotApplication.ps1 new file mode 100644 index 0000000..ae8a9ef --- /dev/null +++ b/setup/cyot/Step1-Register-CyotApplication.ps1 @@ -0,0 +1,234 @@ +#Requires -Version 7.0 +#Requires -Modules Microsoft.Graph.Applications, Microsoft.Graph.Authentication + +<# +.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. + + 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. +.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 +.OUTPUTS + System.String. The application (client) ID only. +#> +[CmdletBinding()] +param( + [string] $TenantId, + [string] $ApplicationId, + [Alias('AppName')] + [string] $DisplayName, + [switch] $NonInteractive +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$script:GraphTenantId = $TenantId + +function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } + +function Read-SetupValue { + param( + [string] $Name, + $DefaultValue, + [switch] $Required, + [ValidateSet('String', 'Guid')] + [string] $ValueType = 'String' + ) + + $needsInput = $Required -and + ($null -eq $DefaultValue -or [string]::IsNullOrWhiteSpace("$DefaultValue")) + while ($true) { + $value = $DefaultValue + if ($needsInput -and -not $NonInteractive) { + $answer = Read-Host -Prompt "$Name [required]" + 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 } + } + elseif ($ValueType -eq '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') } + } + + 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 + Write-Host " Customer tenant: $script:GraphTenantId" + 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 Connect-EndpointGraph { + param([string[]] $Scopes = @('Application.ReadWrite.All')) + + $script:GraphTenantId = Read-SetupValue -Name TenantId -DefaultValue $script:GraphTenantId -Required -ValueType Guid + if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw 'Graph authentication requires at least one nonempty scope.' + } + + $context = Get-MgContext -ErrorAction Stop + $canReuse = $context -and $context.AuthType -eq 'Delegated' -and + $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and + $context.Environment -eq 'Global' -and + @($Scopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and + $context.TenantId -eq $script:GraphTenantId + + if (-not $canReuse) { + if ($NonInteractive) { + throw "Connect-MgGraph -TenantId $script:GraphTenantId with scopes $($Scopes -join ', ') before using -NonInteractive." + } + Write-Host " Graph sign-in: customer tenant $script:GraphTenantId" -ForegroundColor Yellow + Connect-MgGraph -TenantId $script:GraphTenantId -Scopes $Scopes ` + -ContextScope Process -Environment Global -NoWelcome -ErrorAction Stop | Out-Null + $context = Get-MgContext -ErrorAction Stop + } + + if (-not $context -or $context.AuthType -ne 'Delegated' -or + $context.TokenCredentialType -eq 'UserProvidedAccessToken' -or + $context.Environment -ne 'Global' -or + @($Scopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or + $context.TenantId -ne $script:GraphTenantId) { + throw 'Microsoft Graph must use a refreshable delegated session in the selected customer tenant with the requested scopes.' + } +} + +function Get-CyotApplication { + param([string] $ApplicationId, [switch] $RequireMultiTenant) + + $ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -Required -ValueType Guid + $matches = @( + 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 = Get-MgApplication -ApplicationId $matches[0].Id ` + -Property Id, AppId, DisplayName, SignInAudience -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 = @( + 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 = 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.' + Update-MgServicePrincipal -ServicePrincipalId $principal.Id -AppRoleAssignmentRequired:$false -ErrorAction Stop | Out-Null + } + return $principal +} + + +Write-Step 'Stage 1: registering the customer application' +$TenantId = Read-SetupValue -Name TenantId -DefaultValue $TenantId -Required -ValueType Guid +$ApplicationId = Read-SetupValue -Name ApplicationId -DefaultValue $ApplicationId -ValueType Guid +if (-not $ApplicationId) { + $DisplayName = Read-SetupValue -Name AppName -DefaultValue $DisplayName -Required +} +$script:GraphTenantId = $TenantId +Connect-EndpointGraph -Scopes @('Application.ReadWrite.All') + +$application = $null +if ([string]::IsNullOrWhiteSpace($ApplicationId)) { + $matches = @( + 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 = 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.' + Update-MgApplication -ApplicationId $application.Id -SignInAudience AzureADMultipleOrgs -ErrorAction Stop | Out-Null + $application = Get-CyotApplication -ApplicationId $ApplicationId -RequireMultiTenant +} +Ensure-CyotEndpointServicePrincipal -ApplicationId $application.AppId | Out-Null +[string] $application.AppId diff --git a/setup/cyot/Step2-Setup-ExternalPhoneProvider.ps1 b/setup/cyot/Step2-Setup-ExternalPhoneProvider.ps1 new file mode 100644 index 0000000..d8b61fe --- /dev/null +++ b/setup/cyot/Step2-Setup-ExternalPhoneProvider.ps1 @@ -0,0 +1,106 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Deploy both CYOT ARM templates. Certificate, Graph and secret setup runs in Azure. +.DESCRIPTION + Requires an existing resource group and a pre-authorized deployment identity. + If both parameter files are missing, collect the required values here and save them. + ARM supplies the resource-name defaults. No other PowerShell script is required. + Existing files are reused without prompting or overwriting. + Each deployment uses ARM what-if confirmation. Step 3 is not invoked. + Function code publishing is separate; this script deploys infrastructure and configuration. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][guid] $TenantId, + [Parameter(Mandatory)][guid] $SubscriptionId, + [Parameter(Mandatory)][string] $ResourceGroup, + [string] $InfrastructureParameters = (Join-Path $PSScriptRoot 'arm\infrastructure.parameters.local.json'), + [string] $ConfigurationParameters = (Join-Path $PSScriptRoot 'arm\function-config.parameters.local.json') +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +if ($TenantId -eq [guid]::Empty -or $SubscriptionId -eq [guid]::Empty) { throw 'TenantId and SubscriptionId must be nonempty GUIDs.' } +function Read-Value($Name, $Default = '') { + $value = Read-Host "$Name [$Default]" + if ([string]::IsNullOrWhiteSpace($value)) { $value = $Default } + if ([string]::IsNullOrWhiteSpace($value)) { throw "$Name is required." } + return $value.Trim() +} +$deployments = @( + @{ Template = Join-Path $PSScriptRoot 'arm\infrastructure.json'; Parameters = $InfrastructureParameters } + @{ Template = Join-Path $PSScriptRoot 'arm\function-config.json'; Parameters = $ConfigurationParameters } +) +$missing = @($deployments | Where-Object { -not (Test-Path -LiteralPath $_.Parameters -PathType Leaf) }) +if ($missing.Count) { + if ($missing.Count -ne 2) { throw 'Only one parameter file exists. Restore the missing file; existing files will not be overwritten.' } + $shared = @{ tenantId = $TenantId.ToString() } + foreach ($name in @('functionAppName', 'applicationId', 'preparationIdentityResourceId')) { $shared[$name] = Read-Value $name } + $shared.planType = Read-Value 'planType (FlexConsumption/Premium)' 'FlexConsumption' + $shared.tokenVersion = [int](Read-Value 'tokenVersion' '1') + if ($shared.planType -notin @('FlexConsumption', 'Premium') -or $shared.tokenVersion -notin @(1, 2)) { throw 'Invalid plan type or token version.' } + $shared.planType = if ($shared.planType -eq 'Premium') { 'Premium' } else { 'FlexConsumption' } + $provider = @{ EPP_PROVIDER_TIMEOUT_MS = '1500'; EPP_PROVIDER_RETRY_INTERVAL_MS = '0' } + foreach ($name in @('EPP_PROVIDER_NAME', 'EPP_PROVIDER_ENDPOINT', 'EPP_PROVIDER_ACCOUNT_NAME', 'EPP_PROVIDER_TENANT_ID', 'EPP_PROVIDER_SCOPE')) { + $provider[$name] = Read-Value $name + } + foreach ($value in @($shared.applicationId, $provider.EPP_PROVIDER_TENANT_ID)) { + $id = [guid]::Empty + if (-not [guid]::TryParse($value, [ref]$id) -or $id -eq [guid]::Empty) { throw 'Application and provider tenant IDs must be nonempty GUIDs.' } + } + $endpoint = $null + if (-not [uri]::TryCreate($provider.EPP_PROVIDER_ENDPOINT, [UriKind]::Absolute, [ref]$endpoint) -or + $endpoint.Scheme -ne 'https' -or $endpoint.UserInfo -or $endpoint.Query -or $endpoint.Fragment) { throw 'Use an HTTPS provider base URL without credentials, a query or a fragment.' } + $sets = @($shared.Clone(), $shared.Clone()) + $sets[0].location = Read-Value 'location' 'westus2' + $sets[1].managedSettings = $provider + for ($i = 0; $i -lt 2; $i++) { + $parameters = @{} + foreach ($name in $sets[$i].Keys) { $parameters[$name] = @{ value = $sets[$i][$name] } } + $json = @{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'; contentVersion = '1.0.0.0'; parameters = $parameters } | ConvertTo-Json -Depth 10 + $file = [IO.File]::Open($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($deployments[$i].Parameters), [IO.FileMode]::CreateNew) + try { $bytes = [Text.UTF8Encoding]::new($false).GetBytes($json); $file.Write($bytes, 0, $bytes.Length) } + finally { $file.Dispose() } + } +} +foreach ($deployment in $deployments) { + foreach ($path in @($deployment.Template, $deployment.Parameters)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing file: $path" } + } + $deployment.Parameters = (Resolve-Path -LiteralPath $deployment.Parameters).Path + $values = (Get-Content -LiteralPath $deployment.Parameters -Raw | ConvertFrom-Json -AsHashtable).parameters + $deployment.Values = $values + if ($values.Contains('tenantId') -and $values.tenantId.value -ne $TenantId.ToString()) { + throw 'The parameter-file tenant does not match -TenantId.' + } +} +$infra = $deployments[0].Values +$config = $deployments[1].Values +foreach ($name in @('functionAppName', 'storageAccountName', 'keyVaultName', 'outboundIdentityName', 'preparationIdentityResourceId', 'tenantId', 'applicationId', 'tokenVersion', 'planType', 'contentShareName')) { + if ($infra.Contains($name) -ne $config.Contains($name)) { throw "Set '$name' in both parameter files, or omit it in both to use ARM defaults." } + if ($infra.Contains($name) -and $config.Contains($name) -and "$($infra[$name].value)" -cne "$($config[$name].value)") { + throw "The two parameter files disagree on '$name'." + } +} +$outboundName = if ($infra.Contains('outboundIdentityName')) { $infra.outboundIdentityName.value } else { "$($infra.functionAppName.value)-outbound" } +$runtimeIdentity = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.ManagedIdentity/userAssignedIdentities/$outboundName" +if ($infra.preparationIdentityResourceId.value -eq $runtimeIdentity) { throw 'Use a separate deployment identity, not the Function outbound identity.' } +if ($infra.Contains('existing') -and $infra.existing.value.Contains('userAssignedIdentities') -and + $infra.existing.value.userAssignedIdentities.Contains($infra.preparationIdentityResourceId.value)) { + throw 'The preparation identity must not be attached to the Function.' +} + +az login --tenant $TenantId --output none +if ($LASTEXITCODE -ne 0) { throw 'Azure sign-in failed.' } +$selectedTenant = az account show --subscription $SubscriptionId --query tenantId --output tsv +if ($LASTEXITCODE -ne 0 -or "$selectedTenant".Trim() -ne $TenantId.ToString()) { + throw 'The subscription is unavailable or belongs to a different tenant.' +} + +foreach ($deployment in $deployments) { + az deployment group create --subscription $SubscriptionId --resource-group $ResourceGroup ` + --template-file $deployment.Template --parameters "@$($deployment.Parameters)" ` + --mode Incremental --confirm-with-what-if --output json + if ($LASTEXITCODE -ne 0) { throw "Deployment failed: $(Split-Path -Leaf $deployment.Template). Later steps were not run." } +} diff --git a/setup/cyot/Step3-Set-CyotPolicy.ps1 b/setup/cyot/Step3-Set-CyotPolicy.ps1 new file mode 100644 index 0000000..1652286 --- /dev/null +++ b/setup/cyot/Step3-Set-CyotPolicy.ps1 @@ -0,0 +1,333 @@ +#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 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$script:GraphTenantId = $TenantId + +function Write-Step { param([string] $Text) Write-Host "`n=== $Text ===" -ForegroundColor Cyan } + +function Read-SetupValue { + param( + [string] $Name, + $DefaultValue, + [switch] $Required, + [ValidateSet('String', 'Boolean', 'HttpsUrl', 'Guid')] + [string] $ValueType = 'String', + [string] $Hint + ) + + $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" } + $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) { + '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') } + } + '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." } + } + 'HttpsUrl' { + $parsedUri = $null + if (-not [Uri]::TryCreate("$value", [UriKind]::Absolute, [ref] $parsedUri) -or + $parsedUri.Scheme -ne 'https') { + $errorText = "-$Name must be an absolute HTTPS URL." + } + } + } + } + + 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 + Write-Host " Customer tenant: $script:GraphTenantId" + 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 Connect-EndpointGraph { + param([string[]] $Scopes = @('Policy.ReadWrite.AuthenticationMethod')) + + $script:GraphTenantId = Read-SetupValue -Name TenantId -DefaultValue $script:GraphTenantId -Required -ValueType Guid + if (-not $Scopes -or @($Scopes | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count) { + throw 'Graph authentication requires at least one nonempty scope.' + } + + $context = Get-MgContext -ErrorAction Stop + $canReuse = $context -and $context.AuthType -eq 'Delegated' -and + $context.TokenCredentialType -ne 'UserProvidedAccessToken' -and + $context.Environment -eq 'Global' -and + @($Scopes | Where-Object { $context.Scopes -notcontains $_ }).Count -eq 0 -and + $context.TenantId -eq $script:GraphTenantId + + if (-not $canReuse) { + if ($NonInteractive) { + throw "Connect-MgGraph -TenantId $script:GraphTenantId with scopes $($Scopes -join ', ') before using -NonInteractive." + } + Write-Host " Graph sign-in: customer tenant $script:GraphTenantId" -ForegroundColor Yellow + Connect-MgGraph -TenantId $script:GraphTenantId -Scopes $Scopes ` + -ContextScope Process -Environment Global -NoWelcome -ErrorAction Stop | Out-Null + $context = Get-MgContext -ErrorAction Stop + } + + if (-not $context -or $context.AuthType -ne 'Delegated' -or + $context.TokenCredentialType -eq 'UserProvidedAccessToken' -or + $context.Environment -ne 'Global' -or + @($Scopes | Where-Object { $context.Scopes -notcontains $_ }).Count -gt 0 -or + $context.TenantId -ne $script:GraphTenantId) { + throw 'Microsoft Graph must use a refreshable delegated session in the selected customer tenant with the requested scopes.' + } +} + + +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 + $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.' + + $script:GraphTenantId = $CustomerTenantId + Connect-EndpointGraph -Scopes @('Policy.ReadWrite.AuthenticationMethod') + $current = Invoke-MgGraphRequest -Method GET -Uri $SchemaStatus.PolicyUri -OutputType PSObject -ErrorAction Stop + $previous = Get-CyotPolicyState -Policy $current + $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-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-MgGraphRequest -Method PATCH -Uri $SchemaStatus.PolicyUri -Body $body ` + -ContentType 'application/json' -Headers $headers -ErrorAction Stop | Out-Null + $after = 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/setup/cyot/arm/function-config.json b/setup/cyot/arm/function-config.json new file mode 100644 index 0000000..dd02bff --- /dev/null +++ b/setup/cyot/arm/function-config.json @@ -0,0 +1,248 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "2.0.0.0", + "metadata": { + "description": "Prepare the certificate, Graph registration and private-key secret in Azure, then configure the Function. Requires a separately pre-authorized deployment identity." + }, + "parameters": { + "functionAppName": { "type": "string", "minLength": 2, "maxLength": 60 }, + "storageAccountName": { "type": "string", "defaultValue": "[format('st{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", "minLength": 3, "maxLength": 24 }, + "keyVaultName": { "type": "string", "defaultValue": "[format('kv-{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", "minLength": 3, "maxLength": 24 }, + "outboundIdentityName": { "type": "string", "defaultValue": "[format('{0}-outbound', parameters('functionAppName'))]", "minLength": 1 }, + "preparationIdentityResourceId": { + "type": "string", + "minLength": 1, + "metadata": { "description": "Existing deployment-only UAMI with Graph application permissions and ownership of the Step 1 app. Must not be attached to the Function." } + }, + "tenantId": { "type": "string", "minLength": 36, "maxLength": 36 }, + "applicationId": { "type": "string", "minLength": 36, "maxLength": 36 }, + "tokenVersion": { "type": "int", "defaultValue": 1, "allowedValues": [1, 2] }, + "planType": { "type": "string", "defaultValue": "FlexConsumption", "allowedValues": ["FlexConsumption", "Premium"] }, + "functionRoute": { "type": "string", "defaultValue": "api/SendOtp" }, + "contentShareName": { "type": "string", "defaultValue": "function-content" }, + "setupRevision": { + "type": "string", + "defaultValue": "1", + "metadata": { "description": "Increment deliberately to rerun preparation. Existing valid certificates and matching Graph credentials are reused." } + }, + "managedSettings": { + "type": "object", + "defaultValue": {}, + "metadata": { "description": "Nonsecret EPP provider name, endpoint, timeout, account, provider-tenant and scope settings." } + }, + "existingAppSettings": { + "type": "secureObject", + "defaultValue": {}, + "metadata": { "description": "Optional existing unrelated settings to preserve. Exclude obsolete host/runtime settings and never check secret values into source control." } + } + }, + "variables": { + "functionId": "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]", + "vaultId": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "outboundId": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('outboundIdentityName'))]", + "preparationName": "[format('cyot-prepare-{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", + "requiredSettings": { + "AzureWebJobsStorage__accountName": "[parameters('storageAccountName')]", + "AzureWebJobsStorage__credential": "managedidentity", + "APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD", + "EPP_DECRYPTION_KEY_PEM": "[format('@Microsoft.KeyVault(VaultName={0};SecretName=phone-provider-decryption-key)', parameters('keyVaultName'))]", + "EPP_OUTBOUND_CLIENT_ID": "[parameters('applicationId')]", + "EPP_PROVIDER_AUTH_MODE": "ests", + "EPP_EXPECTED_CLIENT_ID": "25ec60fa-f18d-41a4-b398-50044c90ce13", + "EPP_TENANT_ID": "[parameters('tenantId')]", + "EPP_EXPECTED_ISSUER": "[if(equals(parameters('tokenVersion'), 1), format('https://sts.windows.net/{0}/', parameters('tenantId')), format('https://login.microsoftonline.com/{0}/v2.0', parameters('tenantId')))]" + }, + "premiumSettings": { + "FUNCTIONS_WORKER_RUNTIME": "node", + "FUNCTIONS_EXTENSION_VERSION": "~4", + "WEBSITE_NODE_DEFAULT_VERSION": "~24", + "WEBSITE_RUN_FROM_PACKAGE": "1", + "WEBSITE_CONTENTSHARE": "[parameters('contentShareName')]", + "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING": "[format('@Microsoft.KeyVault(VaultName={0};SecretName=phone-provider-content-storage)', parameters('keyVaultName'))]", + "WEBSITE_SKIP_CONTENTSHARE_VALIDATION": "1" + }, + "preparationScript": [ + "python3 - <<'PY'", + "import os, json, re, time, uuid, base64, hashlib, subprocess", + "from datetime import datetime, timezone", + "from urllib.request import Request, urlopen", + "from urllib.error import HTTPError, URLError", + "from urllib.parse import urlencode", + "", + "app_id = str(uuid.UUID(os.environ['APPLICATION_ID']))", + "vault = os.environ['VAULT_URI'].rstrip('/')", + "host = os.environ['FUNCTION_HOST']", + "route = os.environ['FUNCTION_ROUTE'].lstrip('/')", + "tenant = str(uuid.UUID(os.environ['TENANT_ID']))", + "principal = str(uuid.UUID(os.environ['OUTBOUND_PRINCIPAL_ID']))", + "runner = os.environ['PREPARATION_IDENTITY_ID'].lower()", + "attached = json.loads(os.environ['FUNCTION_IDENTITIES'])", + "if runner in {key.lower() for key in attached}:", + " raise RuntimeError('The privileged preparation identity must not be attached to the Function.')", + "tokens = {}", + "", + "def call(method, url, data=None, missing=False):", + " if url.startswith(vault + '/'):", + " resource = 'https://vault.azure.net'", + " elif url.startswith('https://graph.microsoft.com/'):", + " resource = 'https://graph.microsoft.com'", + " else:", + " raise RuntimeError('Unexpected API destination.')", + " if resource not in tokens:", + " result = subprocess.run(['az', 'account', 'get-access-token', '--resource', resource, '--query', 'accessToken', '-o', 'tsv'], capture_output=True, text=True)", + " if result.returncode or not result.stdout.strip():", + " raise RuntimeError('Unable to acquire the deployment identity token; verify its pre-authorized permissions.')", + " tokens[resource] = result.stdout.strip()", + " body = None if data is None else json.dumps(data).encode()", + " request = Request(url, data=body, method=method, headers={'Authorization': 'Bearer ' + tokens[resource], 'Content-Type': 'application/json'})", + " for attempt in range(6):", + " try:", + " with urlopen(request, timeout=60) as response:", + " text = response.read()", + " return json.loads(text) if text else {}", + " except HTTPError as error:", + " if missing and error.code == 404:", + " return None", + " detail = json.loads(error.read() or b'{}').get('error', {})", + " if resource.endswith('vault.azure.net') and error.code == 403 and detail.get('innererror', {}).get('code') == 'ForbiddenByRbac' and attempt < 5:", + " time.sleep(10)", + " continue", + " raise RuntimeError('API request failed: HTTP ' + str(error.code) + '; verify Graph ownership/permissions or vault access.') from None", + " except URLError:", + " raise RuntimeError('The deployment runner cannot reach the required API.') from None", + "", + "query = urlencode({'$filter': \"appId eq '\" + app_id + \"'\", '$select': 'id,appId'})", + "apps = call('GET', 'https://graph.microsoft.com/v1.0/applications?' + query)['value']", + "if len(apps) != 1:", + " raise RuntimeError('The Step 1 application is not visible to this deployment identity.')", + "app_url = 'https://graph.microsoft.com/v1.0/applications/' + apps[0]['id']", + "app = call('GET', app_url + '?$select=id,appId,signInAudience,api,identifierUris,keyCredentials,tokenEncryptionKeyId')", + "if app['signInAudience'] != 'AzureADMultipleOrgs':", + " raise RuntimeError('The Step 1 application must be multi-tenant.')", + "actual_version = (app.get('api') or {}).get('requestedAccessTokenVersion') or 1", + "if actual_version != int(os.environ['TOKEN_VERSION']):", + " raise RuntimeError('tokenVersion must match the existing app and infrastructure parameters.')", + "", + "cert_url = vault + '/certificates/phone-provider-encryption'", + "cert = call('GET', cert_url + '?api-version=7.4', missing=True)", + "key_secret = vault + '/secrets/phone-provider-decryption-key'", + "old_secret = call('GET', key_secret + '?api-version=7.4', missing=True)", + "if cert is None:", + " if old_secret is not None:", + " raise RuntimeError('An existing decryption key has no managed certificate. Import its matching certificate into Key Vault as phone-provider-encryption, or use a fresh vault. No key was replaced.')", + " policy = {'policy': {'issuer': {'name': 'Self'}, 'key_props': {'exportable': True, 'kty': 'RSA', 'key_size': 2048, 'reuse_key': True}, 'secret_props': {'contentType': 'application/x-pem-file'}, 'x509_props': {'subject': 'CN=CYOT ' + app_id, 'validity_months': 12, 'key_usage': ['keyEncipherment', 'dataEncipherment']}, 'lifetime_actions': []}}", + " call('POST', cert_url + '/create?api-version=7.4', policy)", + " for attempt in range(20):", + " cert = call('GET', cert_url + '?api-version=7.4', missing=True)", + " if cert is not None:", + " break", + " time.sleep(3)", + " if cert is None:", + " raise RuntimeError('Certificate creation did not complete.')", + "if cert['attributes']['exp'] <= time.time():", + " raise RuntimeError('The existing certificate has expired. Renew it deliberately, then increment setupRevision.')", + "policy = call('GET', cert_url + '/policy?api-version=7.4')", + "if policy['key_props']['kty'] != 'RSA' or policy['key_props']['key_size'] < 2048 or not policy['key_props']['exportable']:", + " raise RuntimeError('An exportable RSA certificate of at least 2048 bits is required.')", + "secret = call('GET', cert['sid'] + '?api-version=7.4')['value']", + "if '-----BEGIN' not in secret:", + " secret = base64.b64decode(secret, validate=True).decode('utf-8')", + "match = re.search('-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----', secret, re.S)", + "if not match:", + " raise RuntimeError('The certificate secret must contain PKCS8 PEM private-key material.')", + "private_key = match.group(0) + chr(10)", + "if old_secret is None or old_secret['value'] != private_key:", + " call('PUT', key_secret + '?api-version=7.4', {'value': private_key, 'contentType': 'application/x-pem-file'})", + "content = os.environ.get('CONTENT_STORAGE_CONNECTION', '')", + "if content:", + " content_url = vault + '/secrets/phone-provider-content-storage?api-version=7.4'", + " old_content = call('GET', content_url, missing=True)", + " if old_content is None or old_content['value'] != content:", + " call('PUT', content_url, {'value': content, 'contentType': 'text/plain'})", + "", + "der = base64.b64decode(cert['cer'])", + "thumbprint = base64.b64encode(hashlib.sha1(der).digest()).decode()", + "credentials = app.get('keyCredentials') or []", + "matched = next((key for key in credentials if key.get('customKeyIdentifier') == thumbprint), None)", + "key_id = matched['keyId'] if matched else str(uuid.uuid5(uuid.UUID(app_id), hashlib.sha1(der).hexdigest()))", + "if matched is None:", + " if any(not key.get('key') for key in credentials):", + " raise RuntimeError('Existing public key material was not returned; refusing to replace the credential list.')", + " def iso(value):", + " return datetime.fromtimestamp(value, timezone.utc).isoformat()", + " credentials.append({'customKeyIdentifier': thumbprint, 'displayName': 'CYOT encryption', 'key': cert['cer'], 'keyId': key_id, 'type': 'AsymmetricX509Cert', 'usage': 'Encrypt', 'startDateTime': iso(cert['attributes']['nbf']), 'endDateTime': iso(cert['attributes']['exp'])})", + "identifier = 'api://' + host + '/' + app_id", + "uris = app.get('identifierUris') or []", + "if matched is None or identifier not in uris or app.get('tokenEncryptionKeyId') != key_id:", + " patch = {'identifierUris': list(dict.fromkeys(uris + [identifier])), 'tokenEncryptionKeyId': key_id}", + " if matched is None:", + " patch['keyCredentials'] = credentials", + " call('PATCH', app_url, patch)", + "", + "issuer = 'https://login.microsoftonline.com/' + tenant + '/v2.0'", + "federation_url = app_url + '/federatedIdentityCredentials'", + "federations = call('GET', federation_url)['value']", + "name = 'cyot-' + os.environ['FUNCTION_NAME'] + '-outbound'", + "matched = any(item['issuer'] == issuer and item['subject'] == principal and item['audiences'] == ['api://AzureADTokenExchange'] for item in federations)", + "if not matched:", + " if any(item['name'] == name for item in federations):", + " raise RuntimeError('The existing federation name has different trust settings; it will not be replaced.')", + " call('POST', federation_url, {'name': name, 'issuer': issuer, 'subject': principal, 'audiences': ['api://AzureADTokenExchange']})", + "", + "outputs = {'encryptionKeyId': key_id, 'decryptionSecretUri': key_secret, 'identifierUri': identifier, 'endpointUrl': 'https://' + host + '/' + route}", + "with open(os.environ['AZ_SCRIPTS_OUTPUT_PATH'], 'w') as output_file:", + " json.dump(outputs, output_file)", + "print('Certificate, Graph registration and Key Vault preparation completed. No secret values are emitted.')", + "PY" + ] + }, + "resources": [ + { + "type": "Microsoft.Resources/deploymentScripts", + "apiVersion": "2023-08-01", + "name": "[variables('preparationName')]", + "location": "[resourceGroup().location]", + "kind": "AzureCLI", + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { "[parameters('preparationIdentityResourceId')]": {} } + }, + "properties": { + "azCliVersion": "2.47.0", + "timeout": "PT15M", + "retentionInterval": "P1D", + "cleanupPreference": "Always", + "forceUpdateTag": "[parameters('setupRevision')]", + "scriptContent": "[join(variables('preparationScript'), base64ToString('Cg=='))]", + "environmentVariables": [ + { "name": "APPLICATION_ID", "value": "[parameters('applicationId')]" }, + { "name": "TENANT_ID", "value": "[parameters('tenantId')]" }, + { "name": "TOKEN_VERSION", "value": "[string(parameters('tokenVersion'))]" }, + { "name": "VAULT_URI", "value": "[reference(variables('vaultId'), '2023-07-01').vaultUri]" }, + { "name": "FUNCTION_NAME", "value": "[parameters('functionAppName')]" }, + { "name": "FUNCTION_HOST", "value": "[reference(variables('functionId'), '2024-04-01').defaultHostName]" }, + { "name": "FUNCTION_ROUTE", "value": "[parameters('functionRoute')]" }, + { "name": "OUTBOUND_PRINCIPAL_ID", "value": "[reference(variables('outboundId'), '2023-01-31').principalId]" }, + { "name": "PREPARATION_IDENTITY_ID", "value": "[parameters('preparationIdentityResourceId')]" }, + { "name": "CONTENT_STORAGE_CONNECTION", "secureValue": "[if(equals(parameters('planType'), 'Premium'), format('DefaultEndpointsProtocol=https;AccountName={0};AccountKey={1};EndpointSuffix={2}', parameters('storageAccountName'), listKeys(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), '2023-05-01').keys[0].value, environment().suffixes.storage), '')]" }, + { "name": "FUNCTION_IDENTITIES", "value": "[string(reference(variables('functionId'), '2024-04-01', 'Full').identity.userAssignedIdentities)]" } + ] + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-09-01", + "name": "[format('{0}/appsettings', parameters('functionAppName'))]", + "dependsOn": [ + "[resourceId('Microsoft.Resources/deploymentScripts', variables('preparationName'))]" + ], + "properties": "[union(parameters('existingAppSettings'), parameters('managedSettings'), variables('requiredSettings'), if(equals(parameters('planType'), 'Premium'), variables('premiumSettings'), json('{}')), createObject('APPLICATIONINSIGHTS_CONNECTION_STRING', reference(resourceId('Microsoft.Insights/components', parameters('functionAppName')), '2020-02-02').ConnectionString, 'EPP_ENCRYPTION_KEY_ID', reference(resourceId('Microsoft.Resources/deploymentScripts', variables('preparationName')), '2023-08-01').outputs.encryptionKeyId, 'EPP_OUTBOUND_MI_CLIENT_ID', reference(variables('outboundId'), '2023-01-31').clientId, 'EPP_EXPECTED_AUDIENCE', if(equals(parameters('tokenVersion'), 2), parameters('applicationId'), reference(resourceId('Microsoft.Resources/deploymentScripts', variables('preparationName')), '2023-08-01').outputs.identifierUri)))]" + } + ], + "outputs": { + "functionAppResourceId": { "type": "string", "value": "[variables('functionId')]" }, + "endpointUrl": { "type": "string", "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', variables('preparationName')), '2023-08-01').outputs.endpointUrl]" }, + "applicationId": { "type": "string", "value": "[parameters('applicationId')]" }, + "encryptionKeyId": { "type": "string", "value": "[reference(resourceId('Microsoft.Resources/deploymentScripts', variables('preparationName')), '2023-08-01').outputs.encryptionKeyId]" } + } +} diff --git a/setup/cyot/arm/function-config.parameters.sample.json b/setup/cyot/arm/function-config.parameters.sample.json new file mode 100644 index 0000000..b91f434 --- /dev/null +++ b/setup/cyot/arm/function-config.parameters.sample.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "functionAppName": { "value": "replace-with-function-name" }, + "preparationIdentityResourceId": { + "value": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/deployment-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/cyot-preparation" + }, + "tenantId": { "value": "11111111-1111-1111-1111-111111111111" }, + "applicationId": { "value": "22222222-2222-2222-2222-222222222222" }, + "tokenVersion": { "value": 1 }, + "planType": { "value": "FlexConsumption" }, + "functionRoute": { "value": "api/SendOtp" }, + "contentShareName": { "value": "function-content" }, + "setupRevision": { "value": "1" }, + "managedSettings": { + "value": { + "EPP_PROVIDER_NAME": "replace-with-adapter-id", + "EPP_PROVIDER_ENDPOINT": "https://provider.example.com", + "EPP_PROVIDER_TIMEOUT_MS": "1500", + "EPP_PROVIDER_RETRY_INTERVAL_MS": "0", + "EPP_PROVIDER_ACCOUNT_NAME": "replace-with-account", + "EPP_PROVIDER_TENANT_ID": "44444444-4444-4444-4444-444444444444", + "EPP_PROVIDER_SCOPE": "api://replace-with-provider-api/.default" + } + }, + "existingAppSettings": { "value": {} } + } +} diff --git a/setup/cyot/arm/infrastructure.json b/setup/cyot/arm/infrastructure.json new file mode 100644 index 0000000..2bcaa0a --- /dev/null +++ b/setup/cyot/arm/infrastructure.json @@ -0,0 +1,601 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "description": "CYOT infrastructure. The second ARM deployment performs certificate, Graph and secret preparation in Azure.", + "sources": [ + "https://learn.microsoft.com/azure/azure-functions/functions-infrastructure-as-code", + "https://learn.microsoft.com/azure/azure-resource-manager/templates/linked-templates", + "https://learn.microsoft.com/azure/app-service/app-service-key-vault-references" + ] + }, + "parameters": { + "functionAppName": { + "type": "string", + "minLength": 2, + "maxLength": 60, + "metadata": { + "description": "Function App name, also used for its Application Insights component." + } + }, + "storageAccountName": { + "type": "string", + "defaultValue": "[format('st{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", + "minLength": 3, + "maxLength": 24 + }, + "keyVaultName": { + "type": "string", + "defaultValue": "[format('kv-{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", + "minLength": 3, + "maxLength": 24 + }, + "outboundIdentityName": { + "type": "string", + "defaultValue": "[format('{0}-outbound', parameters('functionAppName'))]", + "minLength": 1 + }, + "hostingPlanName": { + "type": "string", + "defaultValue": "[format('plan-{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", + "minLength": 1 + }, + "workspaceName": { + "type": "string", + "defaultValue": "[format('logs-{0}', uniqueString(resourceGroup().id, parameters('functionAppName')))]", + "minLength": 1 + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "minLength": 1 + }, + "preparationIdentityResourceId": { + "type": "string", + "minLength": 1, + "metadata": { + "description": "Existing, separately authorized user-assigned identity used only for deployment preparation, never the running Function. Graph permissions and application ownership must be granted beforehand." + } + }, + "tenantId": { + "type": "string", + "minLength": 1 + }, + "applicationId": { + "type": "string", + "minLength": 1, + "metadata": { + "description": "Existing customer application client ID returned by Step 1, not an application object ID." + } + }, + "planType": { + "type": "string", + "defaultValue": "FlexConsumption", + "allowedValues": [ + "FlexConsumption", + "Premium" + ] + }, + "tokenVersion": { + "type": "int", + "defaultValue": 1, + "allowedValues": [ + 1, + 2 + ] + }, + "enableEasyAuth": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "False skips authsettingsV2 entirely for the imported workflow's custom-authentication path; it does not disable an existing configuration." + } + }, + "tags": { + "type": "object", + "defaultValue": { "Purpose": "Entra - External Phone Provider" }, + "metadata": { + "description": "Setup-managed tags. These override matching existing tags without removing unrelated tags." + } + }, + "deploymentContainerName": { + "type": "string", + "defaultValue": "function-releases", + "minLength": 3, + "maxLength": 63 + }, + "contentShareName": { + "type": "string", + "defaultValue": "function-content", + "minLength": 3, + "maxLength": 63 + }, + "existing": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Nonsecret, preflight-approved snapshots: optional tags and locations by resource key, userAssignedIdentities, roleAssignmentNames, siteConfig, siteProperties, storageProperties, vaultProperties, planProperties, workspaceProperties and insightsProperties. Omit absent subobjects. Site snapshots must exclude appSettings, connectionStrings and functionAppConfig; the script validates compatible resources before deployment." + } + } + }, + "variables": { + "isFlex": "[equals(parameters('planType'), 'FlexConsumption')]", + "functionAppResourceId": "[resourceId('Microsoft.Web/sites', parameters('functionAppName'))]", + "storageAccountResourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "keyVaultResourceId": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "outboundIdentityResourceId": "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', parameters('outboundIdentityName'))]", + "hostingPlanResourceId": "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]", + "workspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspaceName'))]", + "applicationInsightsResourceId": "[resourceId('Microsoft.Insights/components', parameters('functionAppName'))]", + "deploymentContainerResourceId": "[resourceId('Microsoft.Storage/storageAccounts/blobServices/containers', parameters('storageAccountName'), 'default', parameters('deploymentContainerName'))]", + "contentShareResourceId": "[resourceId('Microsoft.Storage/storageAccounts/fileServices/shares', parameters('storageAccountName'), 'default', parameters('contentShareName'))]", + "existingDefaults": { + "tags": { + "storage": {}, + "vault": {}, + "plan": {}, + "workspace": {}, + "insights": {}, + "outboundIdentity": {}, + "function": {} + }, + "locations": { + "storage": "[parameters('location')]", + "vault": "[parameters('location')]", + "plan": "[parameters('location')]", + "workspace": "[parameters('location')]", + "insights": "[parameters('location')]", + "outboundIdentity": "[parameters('location')]", + "function": "[parameters('location')]" + }, + "userAssignedIdentities": {}, + "roleAssignmentNames": {}, + "siteConfig": {}, + "siteProperties": {}, + "storageProperties": {}, + "vaultProperties": {}, + "planProperties": {}, + "workspaceProperties": {}, + "insightsProperties": {} + }, + "existingState": "[union(variables('existingDefaults'), parameters('existing'))]", + "storageDefaults": { + "accessTier": "Hot", + "allowSharedKeyAccess": true + }, + "managedStorageProperties": { + "supportsHttpsTrafficOnly": true, + "minimumTlsVersion": "TLS1_2", + "allowBlobPublicAccess": false + }, + "managedVaultProperties": { + "tenantId": "[parameters('tenantId')]", + "sku": { + "family": "A", + "name": "standard" + }, + "enableRbacAuthorization": true, + "enableSoftDelete": true + }, + "managedWorkspaceProperties": { + "retentionInDays": 30, + "sku": { + "name": "PerGB2018" + } + }, + "managedInsightsProperties": { + "Application_Type": "web", + "WorkspaceResourceId": "[variables('workspaceResourceId')]", + "DisableLocalAuth": true + }, + "flexPlanSku": { + "name": "FC1", + "tier": "FlexConsumption" + }, + "premiumPlanSku": { + "name": "EP1", + "tier": "ElasticPremium", + "family": "EP", + "capacity": 1 + }, + "premiumPlanDefaults": { + "maximumElasticWorkerCount": 20 + }, + "flexSiteConfig": { + "minTlsVersion": "1.2", + "scmMinTlsVersion": "1.2" + }, + "premiumSiteConfig": { + "minTlsVersion": "1.2", + "scmMinTlsVersion": "1.2", + "linuxFxVersion": "NODE|24", + "minimumElasticInstanceCount": 1, + "preWarmedInstanceCount": 1 + }, + "managedSiteProperties": { + "serverFarmId": "[variables('hostingPlanResourceId')]", + "httpsOnly": true, + "keyVaultReferenceIdentity": "SystemAssigned", + "siteConfig": "[union(variables('existingState').siteConfig, if(variables('isFlex'), variables('flexSiteConfig'), variables('premiumSiteConfig')))]" + }, + "flexFunctionAppConfig": { + "runtime": { + "name": "node", + "version": "24" + }, + "scaleAndConcurrency": { + "instanceMemoryMB": 2048, + "maximumInstanceCount": 100, + "alwaysReady": [ + { + "name": "http", + "instanceCount": 1 + } + ] + } + }, + "deploymentStorageAuthentication": { + "type": "SystemAssignedIdentity" + }, + "openIdIssuer": "[if(equals(parameters('tokenVersion'), 1), format('https://sts.windows.net/{0}/', parameters('tenantId')), format('https://login.microsoftonline.com/{0}/v2.0', parameters('tenantId')))]" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-05-01", + "name": "[parameters('storageAccountName')]", + "location": "[variables('existingState').locations.storage]", + "kind": "StorageV2", + "sku": { + "name": "Standard_LRS" + }, + "tags": "[union(variables('existingState').tags.storage, parameters('tags'))]", + "properties": "[union(variables('storageDefaults'), variables('existingState').storageProperties, variables('managedStorageProperties'))]" + }, + { + "condition": "[variables('isFlex')]", + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-05-01", + "name": "[format('{0}/default/{1}', parameters('storageAccountName'), parameters('deploymentContainerName'))]", + "properties": { + "publicAccess": "None" + }, + "dependsOn": [ + "[variables('storageAccountResourceId')]" + ] + }, + { + "condition": "[not(variables('isFlex'))]", + "type": "Microsoft.Storage/storageAccounts/fileServices/shares", + "apiVersion": "2023-05-01", + "name": "[format('{0}/default/{1}', parameters('storageAccountName'), parameters('contentShareName'))]", + "properties": { + "enabledProtocols": "SMB" + }, + "dependsOn": [ + "[variables('storageAccountResourceId')]" + ] + }, + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[parameters('workspaceName')]", + "location": "[variables('existingState').locations.workspace]", + "tags": "[union(variables('existingState').tags.workspace, parameters('tags'))]", + "properties": "[union(variables('existingState').workspaceProperties, variables('managedWorkspaceProperties'))]" + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[parameters('functionAppName')]", + "location": "[variables('existingState').locations.insights]", + "kind": "web", + "tags": "[union(variables('existingState').tags.insights, parameters('tags'))]", + "properties": "[union(variables('existingState').insightsProperties, variables('managedInsightsProperties'))]", + "dependsOn": [ + "[variables('workspaceResourceId')]" + ] + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[parameters('keyVaultName')]", + "location": "[variables('existingState').locations.vault]", + "tags": "[union(variables('existingState').tags.vault, parameters('tags'))]", + "properties": "[union(variables('existingState').vaultProperties, variables('managedVaultProperties'))]" + }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[parameters('outboundIdentityName')]", + "location": "[variables('existingState').locations.outboundIdentity]", + "tags": "[union(variables('existingState').tags.outboundIdentity, parameters('tags'))]" + }, + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2024-04-01", + "name": "[parameters('hostingPlanName')]", + "location": "[variables('existingState').locations.plan]", + "kind": "[if(variables('isFlex'), 'functionapp', 'elastic')]", + "sku": "[if(variables('isFlex'), variables('flexPlanSku'), variables('premiumPlanSku'))]", + "tags": "[union(variables('existingState').tags.plan, parameters('tags'))]", + "properties": "[union(if(variables('isFlex'), json('{}'), variables('premiumPlanDefaults')), variables('existingState').planProperties, createObject('reserved', true()))]" + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[parameters('functionAppName')]", + "location": "[variables('existingState').locations.function]", + "kind": "functionapp,linux", + "tags": "[union(variables('existingState').tags.function, parameters('tags'))]", + "identity": { + "type": "SystemAssigned, UserAssigned", + "userAssignedIdentities": "[union(variables('existingState').userAssignedIdentities, createObject(variables('outboundIdentityResourceId'), json('{}')))]" + }, + "properties": "[union(variables('existingState').siteProperties, variables('managedSiteProperties'), if(variables('isFlex'), createObject('functionAppConfig', union(variables('flexFunctionAppConfig'), createObject('deployment', createObject('storage', createObject('type', 'blobContainer', 'value', format('{0}{1}', reference(variables('storageAccountResourceId'), '2023-05-01').primaryEndpoints.blob, parameters('deploymentContainerName')), 'authentication', variables('deploymentStorageAuthentication')))))), json('{}')))]", + "dependsOn": [ + "[variables('hostingPlanResourceId')]", + "[variables('storageAccountResourceId')]", + "[variables('outboundIdentityResourceId')]", + "[if(variables('isFlex'), variables('deploymentContainerResourceId'), variables('contentShareResourceId'))]" + ] + }, + { + "condition": "[parameters('enableEasyAuth')]", + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-09-01", + "name": "[format('{0}/authsettingsV2', parameters('functionAppName'))]", + "properties": { + "platform": { + "enabled": true + }, + "globalValidation": { + "requireAuthentication": true, + "unauthenticatedClientAction": "Return401", + "excludedPaths": [] + }, + "httpSettings": { + "requireHttps": true + }, + "identityProviders": { + "azureActiveDirectory": { + "enabled": true, + "registration": { + "clientId": "[parameters('applicationId')]", + "openIdIssuer": "[variables('openIdIssuer')]" + }, + "validation": { + "allowedAudiences": [ + "[if(equals(parameters('tokenVersion'), 1), format('api://{0}/{1}', reference(variables('functionAppResourceId'), '2024-04-01').defaultHostName, parameters('applicationId')), parameters('applicationId'))]" + ], + "defaultAuthorizationPolicy": { + "allowedApplications": [ + "25ec60fa-f18d-41a4-b398-50044c90ce13" + ] + } + } + } + }, + "login": { + "tokenStore": { + "enabled": false + } + } + }, + "dependsOn": [ + "[variables('functionAppResourceId')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "[format('cyot-rbac-{0}-{1}', take(parameters('functionAppName'), 32), uniqueString(variables('functionAppResourceId')))]", + "dependsOn": [ + "[variables('functionAppResourceId')]", + "[variables('storageAccountResourceId')]", + "[variables('keyVaultResourceId')]", + "[variables('applicationInsightsResourceId')]" + ], + "properties": { + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "Inner" + }, + "parameters": { + "storageAccountName": { + "value": "[parameters('storageAccountName')]" + }, + "keyVaultName": { + "value": "[parameters('keyVaultName')]" + }, + "applicationInsightsName": { + "value": "[parameters('functionAppName')]" + }, + "systemAssignedPrincipalId": { + "value": "[reference(variables('functionAppResourceId'), '2024-04-01', 'Full').identity.principalId]" + }, + "preparationPrincipalId": { + "value": "[reference(parameters('preparationIdentityResourceId'), '2023-01-31').principalId]" + }, + "existingRoleAssignmentNames": { + "value": "[variables('existingState').roleAssignmentNames]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "storageAccountName": { + "type": "string" + }, + "keyVaultName": { + "type": "string" + }, + "applicationInsightsName": { + "type": "string" + }, + "systemAssignedPrincipalId": { + "type": "string" + }, + "preparationPrincipalId": { + "type": "string" + }, + "existingRoleAssignmentNames": { + "type": "object", + "defaultValue": {} + } + }, + "variables": { + "storageAccountResourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "keyVaultResourceId": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "applicationInsightsResourceId": "[resourceId('Microsoft.Insights/components', parameters('applicationInsightsName'))]", + "storageBlobRoleId": "ba92f5b4-2d11-453d-a403-e96b0029c9fe", + "storageQueueRoleId": "974c5e8b-45b9-4653-ba55-5f855dd0fb88", + "storageTableRoleId": "0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3", + "vaultReaderRoleId": "4633458b-17de-408a-b874-0445c86b69e6", + "vaultWriterRoleId": "b86a8fe4-44ce-4948-aee5-eccb2c155cd7", + "certificateOfficerRoleId": "a4417e6f-fecd-4de8-b567-7b0420556985", + "metricsPublisherRoleId": "3913510d-42f4-4e42-8a64-420c390055eb" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageAccountName'))]", + "name": "[if(contains(parameters('existingRoleAssignmentNames'), 'storageBlob'), parameters('existingRoleAssignmentNames').storageBlob, guid(variables('storageAccountResourceId'), parameters('systemAssignedPrincipalId'), variables('storageBlobRoleId')))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageBlobRoleId'))]", + "principalId": "[parameters('systemAssignedPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageAccountName'))]", + "name": "[if(contains(parameters('existingRoleAssignmentNames'), 'storageQueue'), parameters('existingRoleAssignmentNames').storageQueue, guid(variables('storageAccountResourceId'), parameters('systemAssignedPrincipalId'), variables('storageQueueRoleId')))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageQueueRoleId'))]", + "principalId": "[parameters('systemAssignedPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageAccountName'))]", + "name": "[if(contains(parameters('existingRoleAssignmentNames'), 'storageTable'), parameters('existingRoleAssignmentNames').storageTable, guid(variables('storageAccountResourceId'), parameters('systemAssignedPrincipalId'), variables('storageTableRoleId')))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageTableRoleId'))]", + "principalId": "[parameters('systemAssignedPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', parameters('keyVaultName'))]", + "name": "[if(contains(parameters('existingRoleAssignmentNames'), 'vaultReader'), parameters('existingRoleAssignmentNames').vaultReader, guid(variables('keyVaultResourceId'), parameters('systemAssignedPrincipalId'), variables('vaultReaderRoleId')))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('vaultReaderRoleId'))]", + "principalId": "[parameters('systemAssignedPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', parameters('keyVaultName'))]", + "name": "[guid(variables('keyVaultResourceId'), parameters('preparationPrincipalId'), variables('vaultWriterRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('vaultWriterRoleId'))]", + "principalId": "[parameters('preparationPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', parameters('keyVaultName'))]", + "name": "[guid(variables('keyVaultResourceId'), parameters('preparationPrincipalId'), variables('certificateOfficerRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('certificateOfficerRoleId'))]", + "principalId": "[parameters('preparationPrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Insights/components/{0}', parameters('applicationInsightsName'))]", + "name": "[if(contains(parameters('existingRoleAssignmentNames'), 'metricsPublisher'), parameters('existingRoleAssignmentNames').metricsPublisher, guid(variables('applicationInsightsResourceId'), parameters('systemAssignedPrincipalId'), variables('metricsPublisherRoleId')))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('metricsPublisherRoleId'))]", + "principalId": "[parameters('systemAssignedPrincipalId')]", + "principalType": "ServicePrincipal" + } + } + ] + } + } + } + ], + "outputs": { + "functionAppResourceId": { + "type": "string", + "value": "[variables('functionAppResourceId')]" + }, + "defaultHostName": { + "type": "string", + "value": "[reference(variables('functionAppResourceId'), '2024-04-01').defaultHostName]" + }, + "systemAssignedPrincipalId": { + "type": "string", + "value": "[reference(variables('functionAppResourceId'), '2024-04-01', 'Full').identity.principalId]" + }, + "outboundIdentityResourceId": { + "type": "string", + "value": "[variables('outboundIdentityResourceId')]" + }, + "outboundIdentityClientId": { + "type": "string", + "value": "[reference(variables('outboundIdentityResourceId'), '2023-01-31').clientId]" + }, + "outboundIdentityPrincipalId": { + "type": "string", + "value": "[reference(variables('outboundIdentityResourceId'), '2023-01-31').principalId]" + }, + "storageAccountResourceId": { + "type": "string", + "value": "[variables('storageAccountResourceId')]" + }, + "keyVaultResourceId": { + "type": "string", + "value": "[variables('keyVaultResourceId')]" + }, + "keyVaultUri": { + "type": "string", + "value": "[reference(variables('keyVaultResourceId'), '2023-07-01').vaultUri]" + }, + "applicationInsightsResourceId": { + "type": "string", + "value": "[variables('applicationInsightsResourceId')]" + }, + "deploymentContainerName": { + "type": "string", + "value": "[if(variables('isFlex'), parameters('deploymentContainerName'), '')]" + }, + "contentShareName": { + "type": "string", + "value": "[if(variables('isFlex'), '', parameters('contentShareName'))]" + }, + "contentStorageSecretName": { + "type": "string", + "value": "[if(variables('isFlex'), '', 'phone-provider-content-storage')]" + }, + "planType": { + "type": "string", + "value": "[parameters('planType')]" + } + } +} diff --git a/setup/cyot/arm/parameters.sample.json b/setup/cyot/arm/parameters.sample.json new file mode 100644 index 0000000..79a3ac9 --- /dev/null +++ b/setup/cyot/arm/parameters.sample.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "functionAppName": { + "value": "replace-with-function-name" + }, + "location": { + "value": "westus2" + }, + "preparationIdentityResourceId": { + "value": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/deployment-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/cyot-preparation" + }, + "tenantId": { + "value": "11111111-1111-1111-1111-111111111111" + }, + "applicationId": { + "value": "22222222-2222-2222-2222-222222222222" + }, + "planType": { + "value": "FlexConsumption" + }, + "tokenVersion": { + "value": 1 + }, + "enableEasyAuth": { + "value": true + }, + "deploymentContainerName": { + "value": "function-releases" + }, + "contentShareName": { + "value": "function-content" + }, + "existing": { + "value": {} + } + } +} diff --git a/tests/setup/Test-CyotArm.ps1 b/tests/setup/Test-CyotArm.ps1 new file mode 100644 index 0000000..e4ac97e --- /dev/null +++ b/tests/setup/Test-CyotArm.ps1 @@ -0,0 +1,802 @@ +#Requires -Version 7.0 +# Dot-sourced only by the offline runner: assertions, ASTs, isolated modules and scratch scope are shared. + +function New-ArmTestInput { + param($Document, [string] $Leaf = "$([Guid]::NewGuid()).json") + $directory = Join-Path $script:fixtureDirectory 'inputs' + $null = [IO.Directory]::CreateDirectory($directory) + $path = Join-Path $directory $Leaf + $text = if ($Document -is [string]) { $Document } else { ConvertTo-Json -InputObject $Document -Depth 100 } + [IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false)) + return $path +} + +function Assert-NoArmScratch { + Assert-Equal @(Get-ChildItem -LiteralPath $script:fixtureDirectory -File -Force).Count 0 'all generated request/secret snapshots must be removed' +} + +function Assert-Utf8File { + param([byte[]] $Bytes) + Assert-True ($Bytes.Length -gt 0) 'the captured request file must not be empty' + Assert-True (-not ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF)) 'use UTF-8 without BOM' + $null = [Text.UTF8Encoding]::new($false, $true).GetString($Bytes) +} + +function Assert-NoParameterLeak { + param($Scenario, $Failure = $null) + $text = ($Scenario.State.Messages -join "`n") + ($Scenario.State.Prompts -join "`n") + if ($Failure) { $text += $Failure.Exception.Message } + foreach ($marker in $Scenario.State.Markers) { + Assert-True (-not $text.Contains($marker)) 'logs, prompts and errors must not expose parameter or secret values' + foreach ($call in $Scenario.State.Calls) { + Assert-True (-not (($call.Arguments -join ' ').Contains($marker))) 'send values via the file, never the command line' + } + } +} + +function New-ArmDeploymentScenario { + param([string] $ChangeType = 'Modify', [bool] $NonInteractive = $false, [string] $FailAt = '') + $template = @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' + contentVersion = '1.0.0.0'; resources = @() + parameters = @{ + location = @{ type = 'string' }; tags = @{ type = 'object' } + enabled = @{ type = 'bool' }; count = @{ type = 'int' }; names = @{ type = 'array' } + existingAppSettings = @{ type = 'secureObject' } + } + outputs = @{ functionAppResourceId = @{ type = 'string'; value = 'synthetic-resource-id' } } + } + $state = @{ + TemplatePath = New-ArmTestInput $template + DeploymentName = 'cyot-offline-infrastructure' + Parameters = @{ + location = 'synthetic-location-value'; tags = @{ Purpose = 'Entra - external = offline' } + enabled = $true; count = 7; names = @('alpha', 'beta') + existingAppSettings = @{ USER_KEEP = 'CYOT_SYNTHETIC_PARAMETER_DO_NOT_PRINT' } + } + Preview = @{ status = 'Succeeded'; changes = @(@{ + changeType = $ChangeType; resourceId = "/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers/Microsoft.Web/sites/cyot-offline" + before = @{ hidden = 'CYOT_SYNTHETIC_BEFORE_DO_NOT_PRINT' } + after = @{ hidden = 'CYOT_SYNTHETIC_AFTER_DO_NOT_PRINT' } + delta = @(@{ path = 'properties.secret'; after = 'CYOT_SYNTHETIC_DELTA_DO_NOT_PRINT' }) + }) } + Deployment = @{ properties = @{ provisioningState = 'Succeeded'; outputs = @{ + functionAppResourceId = @{ type = 'String'; value = 'synthetic-resource-id' } + } } } + Calls = [Collections.Generic.List[object]]::new(); Prompts = [Collections.Generic.List[string]]::new() + Markers = @('synthetic-location-value', 'Entra - external = offline', 'CYOT_SYNTHETIC_PARAMETER_DO_NOT_PRINT', + 'CYOT_SYNTHETIC_BEFORE_DO_NOT_PRINT', 'CYOT_SYNTHETIC_AFTER_DO_NOT_PRINT', 'CYOT_SYNTHETIC_DELTA_DO_NOT_PRINT') + Answer = 'Yes'; FailAt = $FailAt; MutateDuringApproval = $false; RawResults = @{} + } + $module = New-OfflineModule $step2 @( + 'Invoke-ArmTemplateDeployment', 'Invoke-AzResult', 'Assert-AzCommandSucceeded', 'Confirm-SetupAction' + ) -State $state -Variables @{ + ResourceGroup = 'cyot-offline-rg'; AzureCliContext = (New-Subscription $selectedSubscription) + GraphTenantId = $customerTenant; NonInteractive = $NonInteractive + } -Mocks @{ + 'Read-Host' = { + param([string] $Prompt) + if ($script:TestState.Prompts.Count) { Stop-UnmockedCall 'Repeated ARM approval' } + $script:TestState.Prompts.Add($Prompt) + if ($script:TestState.MutateDuringApproval) { + [IO.File]::WriteAllText($script:TestState.TemplatePath, '{"changedAfterPreview":true}') + $script:TestState.Parameters.location = 'changed-after-preview' + } + $script:TestState.Answer + } + 'Invoke-AzCommand' = { + param([string[]] $Arguments, [switch] $Interactive) + $operation = $Arguments[0..2] -join ' ' + if ($Interactive -or $operation -notin @('deployment group what-if', 'deployment group create') -or + $script:TestState.Calls.Count -ge 2) { Stop-UnmockedCall "Unexpected ARM command: $operation" } + $parameterIndex = [Array]::IndexOf($Arguments, '--parameters') + $templateIndex = [Array]::IndexOf($Arguments, '--template-file') + if ($parameterIndex -lt 0 -or $templateIndex -lt 0 -or $Arguments[$parameterIndex + 1] -notlike '@*') { + throw 'ARM calls must use external --template-file and --parameters @file.' + } + $parameterPath = [IO.Path]::GetFullPath($Arguments[$parameterIndex + 1].Substring(1)) + $templatePath = [IO.Path]::GetFullPath($Arguments[$templateIndex + 1]) + if ([IO.Path]::GetDirectoryName($parameterPath) -ne $script:FixtureDirectory) { + Stop-UnmockedCall 'ARM parameters outside the test scratch directory' + } + $bytes = [IO.File]::ReadAllBytes($parameterPath) + $script:TestState.Calls.Add([pscustomobject]@{ + Operation = $operation; Arguments = $Arguments; ParameterPath = $parameterPath + ParameterBytes = $bytes; Parameters = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) | ConvertFrom-Json -AsHashtable + TemplatePath = $templatePath; TemplateText = [IO.File]::ReadAllText($templatePath) + }) + if ($operation -eq $script:TestState.FailAt) { + return [pscustomobject]@{ ExitCode = 73; Lines = @('Injected ARM failure: CYOT_SYNTHETIC_PARAMETER_DO_NOT_PRINT') } + } + if ($script:TestState.RawResults.ContainsKey($operation)) { + return [pscustomobject]@{ ExitCode = 0; Lines = @($script:TestState.RawResults[$operation]) } + } + $body = if ($operation -eq 'deployment group what-if') { $script:TestState.Preview } else { $script:TestState.Deployment } + [pscustomobject]@{ ExitCode = 0; Lines = @(ConvertTo-Json -InputObject $body -Depth 100 -Compress) } + } + } + return @{ Module = $module; State = $state } +} + +function Invoke-ArmDeploymentScenario { + param($Scenario) + & $Scenario.Module { + Invoke-ArmTemplateDeployment -TemplatePath $script:TestState.TemplatePath ` + -DeploymentName $script:TestState.DeploymentName -Parameters $script:TestState.Parameters + } +} + +function Assert-ArmCalls { + param($Scenario, [int] $Count = 2) + $calls = $Scenario.State.Calls + Assert-Equal $calls.Count $Count 'bounded what-if/deployment calls without hidden retries' + foreach ($call in $calls) { + Assert-Equal (Get-CliOption $call.Arguments @('--resource-group', '-g')) 'cyot-offline-rg' 'explicit resource group' + Assert-Equal (Get-CliOption $call.Arguments @('--subscription')) $selectedSubscription 'explicit subscription' + Assert-Equal (Get-CliOption $call.Arguments @('--output', '-o')) 'json' 'parse structured CLI results' + Assert-Utf8File $call.ParameterBytes + Assert-Sequence @($call.Parameters.Keys | Sort-Object) @('$schema', 'contentVersion', 'parameters') 'standard ARM parameter document' + Assert-Equal $call.Parameters.'$schema' 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' 'ARM parameter schema' + Assert-Equal $call.Parameters.contentVersion '1.0.0.0' 'ARM parameter document version' + Assert-Equal $call.Parameters.parameters.location.value 'synthetic-location-value' 'serialize string parameters' + Assert-Equal $call.Parameters.parameters.tags.value.Purpose 'Entra - external = offline' 'preserve spaces and equals signs in JSON tags' + Assert-True ($call.Parameters.parameters.enabled.value -is [bool] -and $call.Parameters.parameters.enabled.value) 'preserve Boolean types' + Assert-Equal $call.Parameters.parameters.count.value 7 'preserve integer parameters' + Assert-Sequence $call.Parameters.parameters.names.value @('alpha', 'beta') 'preserve array parameters' + Assert-True ($call.Parameters.parameters.existingAppSettings.value -is [Collections.IDictionary]) 'preserve secure-object shape' + Assert-True ($call.Parameters.parameters.existingAppSettings.value.USER_KEEP -ceq 'CYOT_SYNTHETIC_PARAMETER_DO_NOT_PRINT') 'preserve secure-object values in the file without printing them' + Assert-True (-not (Test-Path -LiteralPath $call.ParameterPath)) 'delete parameter files on every outcome' + } + if ($Count -gt 0) { + Assert-True ($calls[0].Arguments -contains '--no-pretty-print') 'what-if must not render parameter values' + Assert-Equal (Get-CliOption $calls[0].Arguments @('--result-format')) 'ResourceIdOnly' 'request only resource change IDs/types' + } + if ($Count -eq 2) { + Assert-Equal $calls[1].Operation 'deployment group create' 'deploy only after preview' + Assert-Equal (Get-CliOption $calls[1].Arguments @('--mode')) 'Incremental' 'never use complete/deletion mode' + Assert-True ($calls[1].Arguments -notcontains '--no-wait') 'wait for deployment completion' + Assert-Equal $calls[1].ParameterPath $calls[0].ParameterPath 'deploy the approved parameter snapshot' + Assert-Equal $calls[1].TemplatePath $calls[0].TemplatePath 'deploy the approved template snapshot' + Assert-Equal $calls[1].TemplateText $calls[0].TemplateText 'template contents cannot change after preview' + Assert-True (-not (Test-Path -LiteralPath $calls[1].TemplatePath)) 'delete the template snapshot' + } + Assert-NoArmScratch +} + +Invoke-OfflineTest 'ARM deployment uses typed parameter files, safe preview, approval and unwrapped outputs' { + $scenario = New-ArmDeploymentScenario + $output = @(Invoke-ArmDeploymentScenario $scenario) + Assert-Equal $output.Count 1 'return one outputs dictionary' + Assert-True ($output[0] -is [Collections.IDictionary]) 'do not return the whole deployment or SDK objects' + Assert-Sequence @($output[0].Keys) @('functionAppResourceId') 'do not return parameters, properties or secure inputs' + Assert-Equal $output[0].functionAppResourceId 'synthetic-resource-id' 'unwrap each output value' + Assert-Equal $scenario.State.Prompts.Count 1 'changed resources require approval' + Assert-True (($scenario.State.Messages -join "`n").Contains('Modify: /subscriptions/')) 'display resource IDs and change types' + Assert-ArmCalls $scenario + Assert-NoParameterLeak $scenario +} +Invoke-OfflineTest 'ARM deployment snapshots approved files against concurrent template/parameter changes' { + $scenario = New-ArmDeploymentScenario + $scenario.State.MutateDuringApproval = $true + Invoke-ArmDeploymentScenario $scenario | Out-Null + Assert-ArmCalls $scenario + Assert-True (-not $scenario.State.Calls[1].TemplateText.Contains('changedAfterPreview')) 'do not deploy later edits to the source JSON' +} +foreach ($change in @('Create', 'Modify', 'Ignore', 'Deploy', 'Unsupported')) { + Invoke-OfflineTest "ARM noninteractive $change preview refuses deployment" { + $scenario = New-ArmDeploymentScenario -ChangeType $change -NonInteractive $true + $failure = Assert-Throws { Invoke-ArmDeploymentScenario $scenario } -Pattern 'Approval required' -PassThru + Assert-Equal $scenario.State.Prompts.Count 0 'noninteractive mode must not prompt' + Assert-ArmCalls $scenario 1 + Assert-NoParameterLeak $scenario $failure + } +} +foreach ($empty in @($false, $true)) { + Invoke-OfflineTest "ARM noninteractive no-change state is reusable (empty=$empty)" { + $scenario = New-ArmDeploymentScenario -ChangeType 'NoChange' -NonInteractive $true + if ($empty) { $scenario.State.Preview.changes = @() } + Invoke-ArmDeploymentScenario $scenario | Out-Null + Assert-Equal $scenario.State.Prompts.Count 0 'no-change preview needs no approval' + Assert-ArmCalls $scenario + } +} +foreach ($case in @('PreviewCli', 'DeployCli', 'Declined', 'FailedPreview', 'MissingStatus', 'NullPreview', 'ScalarPreview', + 'EmptyPreviewBody', 'WhitespacePreviewBody', 'MalformedPreviewJson', 'MissingChanges', 'NullChanges', 'InvalidChanges', + 'NullChange', 'ScalarChange', 'MissingResourceId', 'Delete', 'UnknownChange', 'PreviewError', + 'PendingDeployment', 'EmptyDeploymentBody', 'MalformedDeploymentJson', 'MissingOutputs', 'NullOutputs', 'OutputArray', + 'MissingDeclaredOutput', 'MissingOutputValue', 'NullOutput', 'SecureOutput', 'MalformedOutput')) { + Invoke-OfflineTest "ARM fails closed and cleans snapshots ($case)" { + $scenario = New-ArmDeploymentScenario + $expectedCalls = 1 + switch ($case) { + 'PreviewCli' { $scenario.State.FailAt = 'deployment group what-if' } + 'DeployCli' { $scenario.State.FailAt = 'deployment group create'; $expectedCalls = 2 } + 'Declined' { $scenario.State.Answer = 'No' } + 'FailedPreview' { $scenario.State.Preview.status = 'Failed' } + 'MissingStatus' { $scenario.State.Preview.Remove('status') } + 'NullPreview' { $scenario.State.Preview = $null } + 'ScalarPreview' { $scenario.State.Preview = 'Succeeded' } + 'EmptyPreviewBody' { $scenario.State.RawResults['deployment group what-if'] = @() } + 'WhitespacePreviewBody' { $scenario.State.RawResults['deployment group what-if'] = @(' ') } + 'MalformedPreviewJson' { $scenario.State.RawResults['deployment group what-if'] = @('{"status":') } + 'MissingChanges' { $scenario.State.Preview.Remove('changes') } + 'NullChanges' { $scenario.State.Preview.changes = $null } + 'InvalidChanges' { $scenario.State.Preview.changes = @{ unexpected = 'not an array' } } + 'NullChange' { $scenario.State.Preview.changes = @($null) } + 'ScalarChange' { $scenario.State.Preview.changes = @('not a change object') } + 'MissingResourceId' { $scenario.State.Preview.changes[0].Remove('resourceId') } + 'Delete' { $scenario.State.Preview.changes[0].changeType = 'Delete' } + 'UnknownChange' { $scenario.State.Preview.changes[0].changeType = 'FutureUnknownChange' } + 'PreviewError' { $scenario.State.Preview.error = @{ code = 'InvalidTemplate' } } + 'PendingDeployment' { $scenario.State.Deployment.properties.provisioningState = 'Running'; $expectedCalls = 2 } + 'EmptyDeploymentBody' { $scenario.State.RawResults['deployment group create'] = @(); $expectedCalls = 2 } + 'MalformedDeploymentJson' { $scenario.State.RawResults['deployment group create'] = @('{"properties":'); $expectedCalls = 2 } + 'MissingOutputs' { $scenario.State.Deployment.properties.Remove('outputs'); $expectedCalls = 2 } + 'NullOutputs' { $scenario.State.Deployment.properties.outputs = $null; $expectedCalls = 2 } + 'OutputArray' { $scenario.State.Deployment.properties.outputs = @(); $expectedCalls = 2 } + 'MissingDeclaredOutput' { $scenario.State.Deployment.properties.outputs = @{}; $expectedCalls = 2 } + 'MissingOutputValue' { $scenario.State.Deployment.properties.outputs.functionAppResourceId.Remove('value'); $expectedCalls = 2 } + 'NullOutput' { $scenario.State.Deployment.properties.outputs.functionAppResourceId = $null; $expectedCalls = 2 } + 'SecureOutput' { $scenario.State.Deployment.properties.outputs.functionAppResourceId.type = 'secureString'; $expectedCalls = 2 } + 'MalformedOutput' { $scenario.State.Deployment.properties.outputs.functionAppResourceId = 'not an output object'; $expectedCalls = 2 } + } + $failure = Assert-Throws { Invoke-ArmDeploymentScenario $scenario } -PassThru + Assert-ArmCalls $scenario $expectedCalls + Assert-NoParameterLeak $scenario $failure + } +} +foreach ($case in @('Missing', 'Unknown')) { + Invoke-OfflineTest "ARM rejects $case parameters before CLI calls" { + $scenario = New-ArmDeploymentScenario + if ($case -eq 'Missing') { $scenario.State.Parameters.Remove('location') } + else { $scenario.State.Parameters.undeclared = 'not accepted' } + Assert-Throws { Invoke-ArmDeploymentScenario $scenario } -Pattern "$case ARM parameter" + Assert-Equal $scenario.State.Calls.Count 0 'reject invalid parameter sets before preview' + Assert-NoArmScratch + } +} + +foreach ($missing in @('', 'infrastructure.json', 'function-config.json')) { + Invoke-OfflineTest "ARM template paths resolve from SetupDirectory, not cwd (missing=$missing)" { + $packageDirectory = Join-Path $script:fixtureDirectory "inputs\package-$([Guid]::NewGuid())" + $armDirectory = Join-Path $packageDirectory 'arm' + $null = [IO.Directory]::CreateDirectory($armDirectory) + foreach ($leaf in @('infrastructure.json', 'function-config.json')) { + if ($leaf -ne $missing) { [IO.File]::WriteAllText((Join-Path $armDirectory $leaf), '{"parameters":{},"resources":[]}') } + } + $module = New-OfflineModule $step2 @('Get-ArmTemplatePaths') -Variables @{ SetupDirectory = $packageDirectory } + if ($missing) { Assert-Throws { & $module { Get-ArmTemplatePaths } } -Pattern 'Missing ARM template' } + else { + $paths = & $module { Get-ArmTemplatePaths } + foreach ($entry in @{ Infrastructure = 'infrastructure.json'; Configuration = 'function-config.json' }.GetEnumerator()) { + Assert-Equal $paths[$entry.Key] (Join-Path $armDirectory $entry.Value) 'load the separate companion JSON file' + Assert-True ([IO.Path]::IsPathFullyQualified($paths[$entry.Key])) 'template paths must be absolute' + } + } + } +} + +foreach ($case in @( + @{ Phase = 'infrastructure'; Limit = 64 } + @{ Phase = 'configuration'; Limit = 64 } + @{ Phase = 'plan'; Limit = 40 } +)) { + Invoke-OfflineTest "Deployment names hash the full Function name within the $($case.Limit)-character limit ($($case.Phase))" { + $module = New-OfflineModule $step2 @('Get-DeploymentName') + $first = 'cyot-' + ('a' * 54) + 'x' + $second = 'cyot-' + ('a' * 54) + 'y' + $names = @(& $module { + param($First, $Second, $Phase, $Limit) + Get-DeploymentName -FunctionName $First -Phase $Phase -MaximumLength $Limit + Get-DeploymentName -FunctionName $Second -Phase $Phase -MaximumLength $Limit + Get-DeploymentName -FunctionName $First -Phase $Phase -MaximumLength $Limit + } $first $second $case.Phase $case.Limit) + Assert-Equal $first.Length 60 'exercise the maximum Function name length' + Assert-Equal $names.Count 3 'return one deployment-name string per call' + foreach ($name in $names) { + Assert-True ($name -is [string] -and $name.Length -le $case.Limit) 'do not exceed the exact ARM name limit' + Assert-True ($name -cmatch '^[A-Za-z0-9-]+$') 'generate valid deployment-name characters' + Assert-True $name.EndsWith("-$($case.Phase)") 'retain the phase identifier' + } + Assert-True ($names[0] -cne $names[1]) 'names sharing the truncated prefix must remain distinct' + Assert-Equal $names[0] $names[2] 'the same input must produce a stable name on rerun' + } +} +Invoke-OfflineTest 'Deployment naming rejects an impossible length limit' { + $module = New-OfflineModule $step2 @('Get-DeploymentName') + Assert-Throws { & $module { Get-DeploymentName -FunctionName 'cyot-offline' -Phase infrastructure -MaximumLength 5 } } -Pattern 'length limit' +} + +function New-SecretScenario { + param([bool] $NonInteractive = $false, [string] $Case = 'Success') + $state = @{ + Value = "CYOT_SYNTHETIC_SECRET_DO_NOT_PRINT`nsecond line = caf$([char]0x00E9)" + Markers = @('CYOT_SYNTHETIC_SECRET_DO_NOT_PRINT'); Case = $Case + Calls = [Collections.Generic.List[object]]::new(); Prompts = [Collections.Generic.List[string]]::new() + Sleeps = 0 + } + $module = New-OfflineModule $step2 @( + 'Set-EndpointSecret', 'Invoke-AzResult', 'Assert-AzCommandSucceeded', 'Confirm-SetupAction' + ) -Variables @{ + NonInteractive = $NonInteractive; AzureCliContext = (New-Subscription $selectedSubscription); GraphTenantId = $customerTenant + } -State $state -Mocks @{ + 'Read-Host' = { param([string] $Prompt) $script:TestState.Prompts.Add($Prompt); if ($script:TestState.Case -eq 'Declined') { 'No' } else { 'Yes' } } + 'Start-Sleep' = { param([int] $Seconds) $script:TestState.Sleeps++; if ($script:TestState.Sleeps -gt 5) { Stop-UnmockedCall 'Unbounded secret retry' } } + 'Invoke-AzCommand' = { + param([string[]] $Arguments, [switch] $Interactive) + if ($Interactive -or ($Arguments[0..2] -join ' ') -ne 'keyvault secret set' -or $script:TestState.Calls.Count -ge 6) { + Stop-UnmockedCall 'Unexpected secret operation' + } + $index = [Array]::IndexOf($Arguments, '--file') + if ($index -lt 0) { throw 'Secret upload must use --file, never --value.' } + $path = [IO.Path]::GetFullPath($Arguments[$index + 1]) + if ([IO.Path]::GetDirectoryName($path) -ne $script:FixtureDirectory) { Stop-UnmockedCall 'Secret file outside test scratch' } + $script:TestState.Calls.Add([pscustomobject]@{ Arguments = $Arguments; Path = $path; Bytes = [IO.File]::ReadAllBytes($path) }) + $case = $script:TestState.Case + if ($case -eq 'CliError') { return [pscustomobject]@{ ExitCode = 74; Lines = @("Injected secret failure: $($script:TestState.Value)") } } + if ($case -eq 'RbacExhausted' -or ($case -eq 'RbacRetry' -and $script:TestState.Calls.Count -eq 1)) { + return [pscustomobject]@{ ExitCode = 75; Lines = @('ForbiddenByRbac') } + } + $uri = switch ($case) { + 'MissingUri' { '' } + 'WrongUri' { 'https://another-vault.vault.azure.net/secrets/phone-provider-decryption-key/synthetic-version' } + default { 'https://cyot-offline-vault.vault.azure.net/secrets/phone-provider-decryption-key/synthetic-version' } + } + [pscustomobject]@{ ExitCode = 0; Lines = @($uri) } + } + } + return @{ Module = $module; State = $state } +} +foreach ($case in @('Success', 'CliError', 'WrongUri', 'MissingUri', 'Declined', 'NonInteractive', 'RbacRetry', 'RbacExhausted')) { + Invoke-OfflineTest "Key Vault secret upload uses a UTF-8 file and cleans it ($case)" { + $scenario = New-SecretScenario -Case $case -NonInteractive ($case -eq 'NonInteractive') + $action = { & $scenario.Module { + Set-EndpointSecret -VaultName 'cyot-offline-vault' -SecretName 'phone-provider-decryption-key' -Value $script:TestState.Value + } } + $failure = $null + if ($case -in @('Success', 'RbacRetry')) { + Assert-Equal (& $action) 'https://cyot-offline-vault.vault.azure.net/secrets/phone-provider-decryption-key' 'return the validated versionless secret URI' + } + else { $failure = Assert-Throws $action -PassThru } + $expectedCount = switch ($case) { 'Declined' { 0 }; 'NonInteractive' { 0 }; 'RbacRetry' { 2 }; 'RbacExhausted' { 6 }; default { 1 } } + Assert-Equal $scenario.State.Calls.Count $expectedCount 'only bounded RBAC-propagation retries are allowed' + foreach ($call in $scenario.State.Calls) { + Assert-True ($call.Arguments -notcontains '--value') 'never put a secret in argv' + Assert-Equal (Get-CliOption $call.Arguments @('--encoding')) 'utf-8' 'tell CLI how to read the secret file' + Assert-Equal (Get-CliOption $call.Arguments @('--subscription')) $selectedSubscription 'pin secret writes to the selected subscription' + Assert-Utf8File $call.Bytes + Assert-True ([Text.Encoding]::UTF8.GetString($call.Bytes) -ceq $scenario.State.Value) 'preserve the complete multiline secret' + Assert-True (-not (Test-Path -LiteralPath $call.Path)) 'remove secret files after success or failure' + } + Assert-NoParameterLeak $scenario $failure + Assert-NoArmScratch + } +} + +$commonObsolete = @('AzureWebJobsStorage', 'DEPLOYMENT_STORAGE_CONNECTION_STRING', 'AzureWebJobsStorage__clientId') +$flexObsolete = @('FUNCTIONS_WORKER_RUNTIME', 'FUNCTIONS_EXTENSION_VERSION', 'WEBSITE_NODE_DEFAULT_VERSION', + 'WEBSITE_RUN_FROM_PACKAGE', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', 'WEBSITE_CONTENTSHARE', 'WEBSITE_SKIP_CONTENTSHARE_VALIDATION') +foreach ($plan in @('FlexConsumption', 'Premium')) { + Invoke-OfflineTest "Appsetting adoption removes only obsolete owned settings ($plan)" { + $settings = @{ UNRELATED_CUSTOM_SETTING = 'preserve me'; EPP_PROVIDER_NAME = 'retained until managed merge' } + foreach ($name in $commonObsolete + $flexObsolete) { $settings[$name] = 'old-owned-value' } + $module = New-OfflineModule $step2 @('Remove-ObsoleteAppSettings') + $result = & $module { param($Settings, $Plan) Remove-ObsoleteAppSettings -Settings $Settings -PlanType $Plan } $settings $plan + $removed = $commonObsolete + $(if ($plan -eq 'FlexConsumption') { $flexObsolete } else { @() }) + foreach ($name in $removed) { Assert-True (-not $result.ContainsKey($name)) "remove obsolete $name" } + foreach ($name in $settings.Keys | Where-Object { $_ -notin $removed }) { + Assert-Equal $result[$name] $settings[$name] "preserve unrelated/remaining setting $name" + } + Assert-Equal $settings.Count ($commonObsolete.Count + $flexObsolete.Count + 2) 'do not mutate the caller snapshot' + Assert-True (-not [object]::ReferenceEquals($settings, $result)) 'return a separate settings dictionary' + } +} + +foreach ($case in @('Adopt', 'None', 'Ambiguous', 'CliError')) { + Invoke-OfflineTest "RBAC adoption matches exact principal, scope and role across pages ($case)" { + $scope = "/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers/Microsoft.Storage/storageAccounts/cyotoffline" + $principal = '66666666-6666-4666-8666-666666666666' + $role = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + $oldName = '77777777-7777-4777-8777-777777777777' + $match = @{ name = $oldName; properties = @{ principalId = $principal; scope = $scope; roleDefinitionId = "/subscriptions/$selectedSubscription/providers/Microsoft.Authorization/roleDefinitions/$role" } } + $decoys = foreach ($field in @('principalId', 'scope', 'roleDefinitionId')) { + $copy = $match | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable + $copy.properties[$field] = 'does-not-match' + $copy + } + $state = @{ + Pages = @( + @{ value = @($decoys) + $(if ($case -eq 'Ambiguous') { @($match) } else { @() }); nextLink = "https://management.azure.com$scope/providers/Microsoft.Authorization/roleAssignments?offlinePage=2" } + @{ value = @($(if ($case -ne 'None') { $match })); nextLink = $null } + ) + Calls = [Collections.Generic.List[object]]::new(); Case = $case + } + $module = New-OfflineModule $step2 @('Get-ExistingRoleAssignmentName', 'Get-AzJson', 'Invoke-Az', 'Invoke-AzResult', 'Assert-AzCommandSucceeded') ` + -Variables @{ AzureCliContext = (New-Subscription $selectedSubscription) } -State $state -Mocks @{ + 'Invoke-AzCommand' = { + param([string[]] $Arguments) + if (($Arguments[0..2] -join ' ') -ne 'rest --method get' -or $script:TestState.Calls.Count -ge 2) { Stop-UnmockedCall 'Unexpected RBAC operation' } + $index = $script:TestState.Calls.Count + $script:TestState.Calls.Add($Arguments) + if ($script:TestState.Case -eq 'CliError') { return [pscustomobject]@{ ExitCode = 76; Lines = @('Injected role inventory error') } } + [pscustomobject]@{ ExitCode = 0; Lines = @(ConvertTo-Json -InputObject $script:TestState.Pages[$index] -Depth 20 -Compress) } + } + } + $action = { & $module { param($Scope, $Principal, $Role) Get-ExistingRoleAssignmentName -Scope $Scope -PrincipalId $Principal -RoleId $Role } $scope $principal $role } + if ($case -in @('Ambiguous', 'CliError')) { Assert-Throws $action } + else { Assert-Equal (& $action) $(if ($case -eq 'Adopt') { $oldName } else { '' }) 'adopt the existing assignment name rather than inventing another ID' } + Assert-Equal $state.Calls.Count $(if ($case -eq 'CliError') { 1 } else { 2 }) 'follow pagination once and stop on errors' + foreach ($arguments in $state.Calls) { + Assert-Equal (Get-CliOption $arguments @('--subscription')) $selectedSubscription 'scope role inventory explicitly' + } + } +} + +foreach ($case in @('Resolved', 'ResolvedFlex', 'Pagination', 'RetryThenResolved', 'MissingDecryption', 'MissingContent', + 'Empty', 'Malformed', 'InvalidSyntax', 'VaultNotFound', 'SecretNotFound', 'SecretVersionNotFound', + 'UnauthorizedClient', 'Pending', 'UnknownStatus', 'RefreshError', 'ReadError')) { + Invoke-OfflineTest "Key Vault reference readiness is bounded, fail-closed and does not expose values ($case)" { + $state = @{ + Case = $case; Attempts = 0; Reads = 0; Page = 0 + ResourceId = "/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers/Microsoft.Web/sites/cyot-offline" + Names = @('EPP_DECRYPTION_KEY_PEM') + $(if ($case -ne 'ResolvedFlex') { @('WEBSITE_CONTENTAZUREFILECONNECTIONSTRING') } else { @() }) + Calls = [Collections.Generic.List[object]]::new(); Sleeps = [Collections.Generic.List[int]]::new() + Prompts = [Collections.Generic.List[string]]::new() + Markers = @('CYOT_REFERENCE_VALUE_DO_NOT_PRINT', 'CYOT_REFERENCE_DETAILS_DO_NOT_PRINT') + } + $module = New-OfflineModule $step2 @('Wait-EndpointConfiguration', 'Get-AzJson', 'Invoke-Az', + 'Invoke-AzResult', 'Assert-AzCommandSucceeded') -Variables @{ + AzureCliContext = (New-Subscription $selectedSubscription) + } -State $state -Mocks @{ + 'Start-Sleep' = { + param([int] $Seconds) + $script:TestState.Sleeps.Add($Seconds) + if ($script:TestState.Sleeps.Count -gt 5) { Stop-UnmockedCall 'Unbounded readiness wait' } + } + 'Invoke-AzCommand' = { + param([string[]] $Arguments, [switch] $Interactive) + if ($Interactive -or $Arguments[0] -ne 'rest') { Stop-UnmockedCall 'Unexpected readiness operation' } + $method = $Arguments[[Array]::IndexOf($Arguments, '--method') + 1] + $url = $Arguments[[Array]::IndexOf($Arguments, '--url') + 1] + $base = "https://management.azure.com$($script:TestState.ResourceId)/config/configreferences/appsettings" + $firstPage = "${base}?api-version=2024-04-01" + $nextPage = $firstPage + '&$skiptoken=synthetic-page-two' + $script:TestState.Calls.Add([pscustomobject]@{ Arguments = $Arguments; Method = $method; Url = $url }) + if ($method -eq 'post' -and $url -eq "$base/refresh?api-version=2024-04-01") { + $script:TestState.Attempts++; $script:TestState.Page = 0 + if ($script:TestState.Attempts -gt 6) { Stop-UnmockedCall 'Unbounded reference refresh' } + $code = if ($script:TestState.Case -eq 'RefreshError') { 77 } else { 0 } + return [pscustomobject]@{ ExitCode = $code; Lines = @('Injected reference refresh response') } + } + if ($method -ne 'get' -or $script:TestState.Attempts -eq 0 -or + ($script:TestState.Page -eq 0 -and $url -ne $firstPage) -or + ($script:TestState.Page -eq 1 -and ($url -ne $nextPage -or $script:TestState.Case -ne 'Pagination')) -or + $script:TestState.Page -gt 1) { Stop-UnmockedCall 'Invalid reference status endpoint/pagination/order' } + $script:TestState.Reads++; $script:TestState.Page++ + if ($script:TestState.Case -eq 'ReadError') { + return [pscustomobject]@{ ExitCode = 78; Lines = @('Injected reference read error') } + } + $records = [Collections.Generic.List[object]]::new() + foreach ($name in $script:TestState.Names) { + if (($script:TestState.Case -eq 'MissingDecryption' -and $name -eq 'EPP_DECRYPTION_KEY_PEM') -or + ($script:TestState.Case -eq 'MissingContent' -and $name -eq 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING') -or + $script:TestState.Case -eq 'Empty') { continue } + if ($script:TestState.Case -eq 'Pagination' -and ( + ($script:TestState.Page -eq 1 -and $name -ne 'EPP_DECRYPTION_KEY_PEM') -or + ($script:TestState.Page -eq 2 -and $name -eq 'EPP_DECRYPTION_KEY_PEM'))) { continue } + $status = switch ($script:TestState.Case) { + { $_ -in @('InvalidSyntax', 'VaultNotFound', 'SecretNotFound', 'SecretVersionNotFound', 'UnauthorizedClient') } { $_ } + 'Pending' { 'AccessToKeyVaultDenied' } + 'UnknownStatus' { 'FutureUnresolvedStatus' } + 'RetryThenResolved' { if ($script:TestState.Attempts -eq 1) { 'AccessToKeyVaultDenied' } else { 'Resolved' } } + default { 'Resolved' } + } + $records.Add(@{ id = "$base/$($name.Replace('_', '%5F'))"; properties = @{ + status = $status; value = 'CYOT_REFERENCE_VALUE_DO_NOT_PRINT'; details = 'CYOT_REFERENCE_DETAILS_DO_NOT_PRINT' + } }) + } + $records.Add(@{ id = "$base/UNRELATED_SETTING"; properties = @{ status = 'UnauthorizedClient' } }) + $page = @{ value = $records.ToArray(); nextLink = $null } + if ($script:TestState.Case -eq 'Empty') { $page.value = @() } + if ($script:TestState.Case -eq 'Malformed') { $page.value = @{ unexpected = 'not a collection' } } + if ($script:TestState.Case -eq 'Pagination' -and $script:TestState.Page -eq 1) { $page.nextLink = $nextPage } + [pscustomobject]@{ ExitCode = 0; Lines = @(ConvertTo-Json -InputObject $page -Depth 15 -Compress) } + } + } + $action = { & $module { Wait-EndpointConfiguration -FunctionResourceId $script:TestState.ResourceId -SettingNames $script:TestState.Names } } + $failure = $null + if ($case -in @('Resolved', 'ResolvedFlex', 'Pagination', 'RetryThenResolved')) { + Assert-Equal @(& $action).Count 0 'readiness success emits no SDK/status objects' + } + else { $failure = Assert-Throws $action -PassThru } + $attempts = if ($case -eq 'RetryThenResolved') { 2 } + elseif ($case -in @('MissingDecryption', 'MissingContent', 'Empty', 'Pending', 'UnknownStatus')) { 6 } + else { 1 } + Assert-Equal $state.Attempts $attempts 'six attempts maximum; fatal syntax/vault/secret/client errors fail immediately' + Assert-Equal $state.Sleeps.Count ($attempts - 1) 'no sleep after the final attempt or a fatal error' + foreach ($seconds in $state.Sleeps) { Assert-Equal $seconds 15 'exact bounded readiness retry interval' } + Assert-Equal $state.Reads $(if ($case -eq 'RefreshError') { 0 } elseif ($case -eq 'Pagination') { 2 } else { $attempts }) 'follow nextLink and refresh before each new attempt' + foreach ($call in $state.Calls) { + Assert-Equal (Get-CliOption $call.Arguments @('--subscription')) $selectedSubscription 'readiness requests retain explicit scope' + } + Assert-NoParameterLeak @{ State = $state } $failure + } +} + +function New-Step2PhaseScenario { + param([string] $Plan = 'FlexConsumption', [string] $FailAt = '', [switch] $ExistingEndpoint) + $group = 'cyot-offline-rg' + $prefix = "/subscriptions/$selectedSubscription/resourceGroups/$group/providers" + $state = @{ + Plan = $Plan; FailAt = $FailAt; ExistingEndpoint = [bool]$ExistingEndpoint + Events = [Collections.Generic.List[string]]::new(); ArmCalls = [Collections.Generic.List[object]]::new() + SecretValues = [Collections.Generic.List[string]]::new(); Certificate = $null + Templates = @{ + Infrastructure = New-ArmTestInput @{ parameters = @{}; resources = @() } "$([Guid]::NewGuid())-infrastructure.json" + Configuration = New-ArmTestInput @{ parameters = @{}; resources = @() } "$([Guid]::NewGuid())-function-config.json" + } + Application = [pscustomobject]@{ + Id = '44444444-4444-4444-8444-444444444444'; AppId = '33333333-3333-4333-8333-333333333333' + Api = [pscustomobject]@{ RequestedAccessTokenVersion = 1 } + } + Infrastructure = @{ + functionAppResourceId = "$prefix/Microsoft.Web/sites/cyot-offline" + defaultHostName = 'actual-arm-host.example.invalid' + systemAssignedPrincipalId = '66666666-6666-4666-8666-666666666666' + outboundIdentityResourceId = "$prefix/Microsoft.ManagedIdentity/userAssignedIdentities/cyot-outbound" + outboundIdentityClientId = '88888888-8888-4888-8888-888888888888' + outboundIdentityPrincipalId = '99999999-9999-4999-8999-999999999999' + storageAccountResourceId = "$prefix/Microsoft.Storage/storageAccounts/cyotoffline" + keyVaultResourceId = "$prefix/Microsoft.KeyVault/vaults/cyot-offline-vault" + keyVaultUri = 'https://cyot-offline-vault.vault.azure.net/' + applicationInsightsResourceId = "$prefix/Microsoft.Insights/components/cyot-offline" + deploymentContainerName = 'old-releases'; contentShareName = $(if ($Plan -eq 'Premium') { 'old-content' } else { '' }) + contentStorageSecretName = $(if ($Plan -eq 'Premium') { 'phone-provider-content-storage' } else { '' }); planType = $Plan + } + Existing = @{ tags = @{ function = @{ KEEP = 'existing tag' } }; userAssignedIdentities = @{ unrelated = @{} } + roleAssignmentNames = @{ storageBlob = '77777777-7777-4777-8777-777777777777' } } + Settings = @{ USER_KEEP = 'synthetic preserved appsetting'; AzureWebJobsStorage = 'obsolete'; WEBSITE_RUN_FROM_PACKAGE = 'obsolete' } + Configuration = @{ functionAppResourceId = "$prefix/Microsoft.Web/sites/cyot-offline" } + } + $variables = @{ + TenantId = $customerTenant; ApplicationId = $state.Application.AppId; FunctionAppName = 'cyot-offline' + EndpointUrl = $(if ($ExistingEndpoint) { 'https://existing-endpoint.example.invalid/api/SendOtp' } else { '' }) + NonInteractive = $true; SubscriptionId = $selectedSubscription; ResourceGroup = $group; Location = 'westus2'; PlanType = $Plan + StorageAccountName = 'cyotoffline'; KeyVaultName = 'cyot-offline-vault'; OutboundIdentityName = 'cyot-outbound' + CertificatePath = ''; ZipPath = 'synthetic-package.zip'; ZipUrl = ''; FunctionRoute = '/api/SendOtp' + ProviderName = 'synthetic-provider'; ProviderEndpoint = 'https://provider.example.invalid' + ProviderTimeoutMs = 1500; ProviderRetryIntervalMs = 0; ProviderAccountName = 'synthetic-account' + ProviderTenantId = $otherTenant; ProviderScope = 'api://provider.example.invalid/.default' + ResourceTagName = 'Purpose'; ResourceTagValue = 'Entra - External = Phone Provider'; NoEasyAuth = $false + ProvidedParameters = @{ ProviderTimeoutMs = 1500; ProviderRetryIntervalMs = 0 } + MicrosoftPhoneProviderAppId = '25ec60fa-f18d-41a4-b398-50044c90ce13'; SetupDirectory = $script:fixtureDirectory + } + $module = New-OfflineModule $step2 @('Invoke-Step2Setup', 'Read-SetupValue', 'Get-ProviderAppSettings', + 'Get-ProviderEntraSettings', 'Get-RequiredArmOutput', 'Get-ResourceId', 'Get-DeploymentName', + 'Remove-ObsoleteAppSettings', 'Write-Step') -State $state -Variables $variables -Mocks @{ + 'Trace-Phase' = { + param([string] $Name) + $script:TestState.Events.Add($Name) + if ($script:TestState.FailAt -eq $Name) { throw "Injected phase failure: $Name" } + } + 'Get-Command' = { + param([string] $Name) + if ($Name -ne 'az' -or $script:TestState.ExistingEndpoint) { Stop-UnmockedCall 'Unexpected Azure CLI discovery' } + [pscustomobject]@{ Name = 'az' } + } + 'Get-ArmTemplatePaths' = { Trace-Phase 'templates'; $script:TestState.Templates } + 'Resolve-FunctionPackage' = { param($Path, $Url) Trace-Phase 'package'; @{ Path = 'synthetic-package.zip'; Temporary = $false } } + 'Initialize-AzureCliAuthentication' = { Trace-Phase 'azure-auth'; $script:AzureCliContext = [pscustomobject]@{ id = $SubscriptionId } } + 'Connect-EndpointGraph' = { param([string[]] $Scopes) Trace-Phase 'graph' } + 'Get-CyotApplication' = { + param([string] $ApplicationId, [switch] $RequireMultiTenant) + if ($ApplicationId -ne $script:TestState.Application.AppId -or -not $RequireMultiTenant) { Stop-UnmockedCall 'Unpinned application lookup' } + Trace-Phase 'application'; $script:TestState.Application + } + 'Get-ExistingDeploymentState' = { + param([string] $DeployerObjectId) + Trace-Phase 'inventory' + @{ hostingPlanName = 'old-plan'; workspaceName = 'old-workspace'; deploymentContainerName = 'old-releases'; contentShareName = 'old-content'; existing = $script:TestState.Existing } + } + 'Invoke-ArmTemplateDeployment' = { + param([string] $TemplatePath, [string] $DeploymentName, [hashtable] $Parameters) + $phase = if ($TemplatePath -eq $script:TestState.Templates.Infrastructure) { 'infrastructure' } + elseif ($TemplatePath -eq $script:TestState.Templates.Configuration) { 'configuration' } + else { Stop-UnmockedCall 'Deployment did not use one of the two separate template paths' } + Trace-Phase $phase + $script:TestState.ArmCalls.Add([pscustomobject]@{ Phase = $phase; TemplatePath = $TemplatePath; DeploymentName = $DeploymentName; Parameters = $Parameters }) + if ($phase -eq 'infrastructure') { $script:TestState.Infrastructure } else { $script:TestState.Configuration } + } + 'Get-EndpointCertificate' = { + param([string] $EndpointHost, [string] $Path) + Trace-Phase 'certificate' + $rsa = [Security.Cryptography.RSA]::Create(2048) + try { + $request = [Security.Cryptography.X509Certificates.CertificateRequest]::new("CN=$EndpointHost", $rsa, + [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $script:TestState.Certificate = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-1), [DateTimeOffset]::UtcNow.AddDays(1)) + } + finally { $rsa.Dispose() } + $script:TestState.Certificate + } + 'Publish-EndpointRegistration' = { + param($Application, $Certificate, [string] $EndpointHost) + Trace-Phase 'registration' + @{ IdentifierUri = "api://$EndpointHost/$($Application.AppId)"; EncryptionKeyId = 'abcdefab-1234-4123-8123-abcdefabcdef' } + } + 'Ensure-ProviderFederation' = { + param($Application, [string] $PrincipalId) + Trace-Phase 'federation' + if ($PrincipalId -ne $script:TestState.Infrastructure.outboundIdentityPrincipalId) { Stop-UnmockedCall 'Federation did not use the actual ARM principal' } + } + 'Set-EndpointSecret' = { + param([string] $VaultName, [string] $SecretName, [string] $Value) + Trace-Phase "secret:$SecretName" + $script:TestState.SecretValues.Add($Value) + "https://$VaultName.vault.azure.net/secrets/$SecretName" + } + 'Get-ExistingAppSettings' = { Trace-Phase 'settings'; $script:TestState.Settings } + 'Wait-EndpointConfiguration' = { + param([string] $FunctionResourceId, [string[]] $SettingNames) + Trace-Phase 'references' + $expectedNames = @('EPP_DECRYPTION_KEY_PEM') + if ($script:TestState.Plan -eq 'Premium') { $expectedNames += 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING' } + if ($FunctionResourceId -ne $script:TestState.Infrastructure.functionAppResourceId -or + $SettingNames.Count -ne $expectedNames.Count -or + @($expectedNames | Where-Object { $SettingNames -notcontains $_ }).Count) { + Stop-UnmockedCall 'Required Key Vault reference readiness was not checked' + } + } + 'Confirm-SetupAction' = { param($Action, $Target, $Details) if ($Action -ne 'deploy Function package') { Stop-UnmockedCall "Unexpected approval: $Action" } } + 'Invoke-Az' = { + if ($script:TestState.ExistingEndpoint) { Stop-UnmockedCall 'Azure CLI in existing-endpoint mode' } + $operation = ($args | Select-Object -First 3) -join ' ' + switch ($operation) { + 'group exists --name' { 'true' } + 'ad signed-in-user show' { '12121212-1212-4212-8212-121212121212' } + 'storage account keys' { Trace-Phase 'content-key'; 'CYOT_SYNTHETIC_STORAGE_KEY_DO_NOT_PRINT' } + 'functionapp deployment source' { + if ($args -notcontains 'config-zip') { Stop-UnmockedCall 'Unexpected publication method' } + Trace-Phase 'zip' + } + default { Stop-UnmockedCall "Unexpected orchestration CLI operation: $operation" } + } + } + } + return @{ Module = $module; State = $state } +} + +foreach ($plan in @('FlexConsumption', 'Premium')) { + Invoke-OfflineTest "One Step2 orchestrator deploys two external templates around Graph/secret work ($plan)" { + $scenario = New-Step2PhaseScenario -Plan $plan + $result = @(& $scenario.Module { Invoke-Step2Setup }) + Assert-Equal $result.Count 1 'return only the original Stage2 result object' + Assert-Sequence @($result[0].PSObject.Properties.Name | Sort-Object) @( + 'ApplicationId', 'CertThumbprint', 'EncryptionKeyId', 'EndpointUrl', 'IdentifierUri', 'Stage', 'TenantId' + ) 'preserve Stage2 output shape' + Assert-Equal $result[0].Stage 2 'do not activate Stage3' + Assert-Equal $result[0].TenantId $customerTenant 'preserve the customer tenant' + Assert-Equal $result[0].EndpointUrl 'https://actual-arm-host.example.invalid/api/SendOtp' 'use the actual ARM hostname' + $expected = @('templates', 'package', 'azure-auth', 'graph', 'application', 'inventory', 'infrastructure', + 'certificate', 'application', 'registration', 'federation', 'secret:phone-provider-decryption-key') + if ($plan -eq 'Premium') { $expected += @('content-key', 'secret:phone-provider-content-storage') } + $expected += @('settings', 'configuration', 'references', 'zip') + Assert-Sequence $scenario.State.Events.ToArray() $expected 'inputs/auth/app validation -> infra -> Graph/secrets -> configuration -> ZIP' + Assert-Equal $scenario.State.ArmCalls.Count 2 'automatically deploy both phases from one invocation' + $infra = $scenario.State.ArmCalls[0].Parameters + $config = $scenario.State.ArmCalls[1].Parameters + Assert-Equal $infra.existing.roleAssignmentNames.storageBlob '77777777-7777-4777-8777-777777777777' 'pass adopted RBAC assignment names to ARM' + Assert-Equal $infra.existing.tags.function.KEEP 'existing tag' 'preserve existing tags in the snapshot' + Assert-True $infra.existing.userAssignedIdentities.ContainsKey('unrelated') 'preserve unrelated user-assigned identities' + Assert-Equal $infra.hostingPlanName 'old-plan' 'reuse the actual hosting plan' + Assert-Equal $infra.workspaceName 'old-workspace' 'reuse the actual workspace' + Assert-Equal $config.existingAppSettings.USER_KEEP 'synthetic preserved appsetting' 'preserve unrelated appsettings' + Assert-True (-not $config.existingAppSettings.ContainsKey('AzureWebJobsStorage')) 'remove obsolete connection-string settings before ARM' + Assert-Equal $config.managedSettings.EPP_OUTBOUND_MI_CLIENT_ID $scenario.State.Infrastructure.outboundIdentityClientId 'use the actual ARM identity client ID' + Assert-Equal $config.contentStorageSecretUri $(if ($plan -eq 'Premium') { 'https://cyot-offline-vault.vault.azure.net/secrets/phone-provider-content-storage' } else { '' }) 'Premium passes only a Key Vault reference, not a storage key' + $serialized = ConvertTo-Json -InputObject @($infra, $config, $result[0]) -Depth 100 + foreach ($secret in $scenario.State.SecretValues) { Assert-True (-not $serialized.Contains($secret)) 'private-key and content secrets must never enter ARM parameters or outputs' } + Assert-True (-not $serialized.Contains('CYOT_SYNTHETIC_STORAGE_KEY_DO_NOT_PRINT')) 'never expose the content-storage key' + } +} +foreach ($phase in @('package', 'azure-auth', 'graph', 'application', 'inventory', 'infrastructure', 'certificate', + 'registration', 'federation', 'secret:phone-provider-decryption-key', 'secret:phone-provider-content-storage', + 'configuration', 'references')) { + Invoke-OfflineTest "Step2 stops later phases after $phase fails" { + $scenario = New-Step2PhaseScenario -Plan Premium -FailAt $phase + Assert-Throws { & $scenario.Module { Invoke-Step2Setup } } -Pattern "Injected phase failure: $([regex]::Escape($phase))" + Assert-Equal $scenario.State.Events[-1] $phase 'nothing may run after the failed phase' + Assert-True (-not $scenario.State.Events.Contains('zip')) 'do not publish code after a failed prerequisite' + if ($phase -in @('package', 'azure-auth', 'graph', 'application', 'inventory', 'infrastructure')) { + Assert-True (-not $scenario.State.Events.Contains('certificate')) 'do not configure certificates/Graph after infra/preflight failure' + Assert-True (-not $scenario.State.Events.Contains('configuration')) 'do not configure the Function after infra/preflight failure' + } + } +} +foreach ($field in @('functionAppResourceId', 'storageAccountResourceId', 'keyVaultResourceId', 'keyVaultUri', + 'outboundIdentityResourceId', 'applicationInsightsResourceId', 'planType', 'systemAssignedPrincipalId', + 'outboundIdentityClientId', 'outboundIdentityPrincipalId', 'defaultHostName', 'contentShareName', 'contentStorageSecretName')) { + Invoke-OfflineTest "Step2 missing infrastructure output $field prevents configuration/publication" { + $scenario = New-Step2PhaseScenario -Plan Premium + $scenario.State.Infrastructure.Remove($field) + Assert-Throws { & $scenario.Module { Invoke-Step2Setup } } + Assert-True (-not $scenario.State.Events.Contains('certificate')) 'missing outputs must stop before certificate or Graph changes' + Assert-True (-not $scenario.State.Events.Contains('configuration')) 'missing outputs must stop before configuration' + Assert-True (-not $scenario.State.Events.Contains('zip')) 'missing outputs must stop before publishing' + } +} +foreach ($field in @('storageAccountResourceId', 'keyVaultResourceId', 'outboundIdentityResourceId', 'applicationInsightsResourceId', + 'planType', 'systemAssignedPrincipalId', 'outboundIdentityClientId', 'outboundIdentityPrincipalId', 'keyVaultUri', + 'contentShareName', 'contentStorageSecretName')) { + Invoke-OfflineTest "Step2 rejects nonempty but invalid infrastructure output $field before Graph mutation" { + $scenario = New-Step2PhaseScenario -Plan Premium + $scenario.State.Infrastructure[$field] = 'wrong-nonempty-output' + Assert-Throws { & $scenario.Module { Invoke-Step2Setup } } + foreach ($phase in @('certificate', 'registration', 'federation', 'configuration', 'zip')) { + Assert-True (-not $scenario.State.Events.Contains($phase)) "invalid output must prevent $phase" + } + } +} +foreach ($subscription in @($null, ' ', 'not-a-guid', [Guid]::Empty.ToString())) { + Invoke-OfflineTest "Step2 noninteractive subscription <$subscription> fails before package resolution" { + $scenario = New-Step2PhaseScenario + & $scenario.Module { param($Value) $script:SubscriptionId = $Value } $subscription + Assert-Throws { & $scenario.Module { Invoke-Step2Setup } } -Pattern 'SubscriptionId.*(required|GUID)' + foreach ($phase in @('package', 'azure-auth', 'graph', 'infrastructure', 'configuration', 'zip')) { + Assert-True (-not $scenario.State.Events.Contains($phase)) "reject an invalid subscription before $phase" + } + } +} +foreach ($case in @('WrongInfraTarget', 'WrongPrincipal', 'WrongConfigTarget', 'MissingConfigTarget', 'InvalidProvider', 'MissingProvider', 'UnsupportedTokenVersion')) { + Invoke-OfflineTest "Step2 refuses invalid phase inputs/results ($case)" { + $scenario = New-Step2PhaseScenario + switch ($case) { + 'WrongInfraTarget' { $scenario.State.Infrastructure.functionAppResourceId = '/subscriptions/another-target' } + 'WrongPrincipal' { $scenario.State.Infrastructure.systemAssignedPrincipalId = 'not-a-guid' } + 'WrongConfigTarget' { $scenario.State.Configuration.functionAppResourceId = '/subscriptions/another-target' } + 'MissingConfigTarget' { $scenario.State.Configuration.Clear() } + 'InvalidProvider' { & $scenario.Module { $script:ProviderScope = 'invalid-scope' } } + 'MissingProvider' { & $scenario.Module { $script:ProviderName = '' } } + 'UnsupportedTokenVersion' { $scenario.State.Application.Api.RequestedAccessTokenVersion = 3 } + } + Assert-Throws { & $scenario.Module { Invoke-Step2Setup } } + Assert-True (-not $scenario.State.Events.Contains('zip')) 'invalid inputs/results cannot reach publishing' + if ($case -in @('InvalidProvider', 'MissingProvider', 'UnsupportedTokenVersion')) { + Assert-True (-not $scenario.State.Events.Contains('infrastructure')) 'finish input/app preflight before provisioning' + } + } +} +Invoke-OfflineTest 'EndpointUrl mode skips template checks, ARM, Azure and package publishing entirely' { + $scenario = New-Step2PhaseScenario -ExistingEndpoint + & $scenario.Module { $script:SubscriptionId = $null; $script:ProviderName = ''; $script:ZipPath = '' } + $result = & $scenario.Module { Invoke-Step2Setup } + Assert-Sequence $scenario.State.Events.ToArray() @('graph', 'application', 'certificate', 'application', 'registration') 'existing endpoints only need directory/certificate configuration' + Assert-Equal $scenario.State.ArmCalls.Count 0 'no ARM in existing-endpoint mode' + Assert-Equal $result.EndpointUrl 'https://existing-endpoint.example.invalid/api/SendOtp' 'preserve the explicitly supplied endpoint' +} +Invoke-OfflineTest 'Step2 keeps a single orchestrator entry point and separate, non-embedded ARM files' { + $ast = $ScriptAsts[$step2] + $topCommands = @(foreach ($statement in $ast.EndBlock.Statements) { + if ($statement -isnot [Management.Automation.Language.FunctionDefinitionAst]) { + $statement.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true) + } + }) + Assert-Equal @($topCommands | Where-Object { $_.GetCommandName() -eq 'Invoke-Step2Setup' }).Count 1 'invoke one orchestrator, not a manual two-script workflow' + Assert-Equal @($topCommands | Where-Object { $_.GetCommandName() -in @('Invoke-Az', 'Invoke-ArmTemplateDeployment') }).Count 0 'all deployment sequencing belongs inside the orchestrator' + $embedded = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.Value -match 'deploymentTemplate\.json|"\$schema"\s*:' + }, $true)) + Assert-Equal $embedded.Count 0 'do not embed ARM template JSON in PowerShell' + $flow = Get-FunctionAst $step2 'Invoke-Step2Setup' + $commands = @($flow.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true)) + $armCalls = @($commands | Where-Object { $_.GetCommandName() -eq 'Invoke-ArmTemplateDeployment' }) + Assert-Equal $armCalls.Count 2 'wire both ARM phases' + foreach ($name in @('Get-ProviderAppSettings', 'Get-ProviderEntraSettings', 'Resolve-FunctionPackage', 'Initialize-AzureCliAuthentication', 'Get-CyotApplication')) { + $preflight = @($commands | Where-Object { $_.GetCommandName() -eq $name }) | Select-Object -First 1 + Assert-True ($null -ne $preflight -and $preflight.Extent.EndOffset -lt $armCalls[0].Extent.StartOffset) "$name must precede infrastructure mutation" + } +} + +. (Join-Path $PSScriptRoot 'Test-CyotArmTemplates.ps1') diff --git a/tests/setup/Test-CyotArmTemplates.ps1 b/tests/setup/Test-CyotArmTemplates.ps1 new file mode 100644 index 0000000..cb1716f --- /dev/null +++ b/tests/setup/Test-CyotArmTemplates.ps1 @@ -0,0 +1,447 @@ +#Requires -Version 7.0 +# A deliberately small, data-only ARM expression reader. No Invoke-Expression, Azure SDK, +# schema downloads or deployment engine. Unknown expressions/references fail closed. + +function Split-ArmArguments { + param([string] $Text) + $parts = [Collections.Generic.List[string]]::new() + $depth = 0; $quoted = $false; $start = 0 + for ($index = 0; $index -lt $Text.Length; $index++) { + $character = $Text[$index] + if ($character -eq "'") { + if ($quoted -and $index + 1 -lt $Text.Length -and $Text[$index + 1] -eq "'") { $index++; continue } + $quoted = -not $quoted + } + elseif (-not $quoted) { + if ($character -in @('(', '[')) { $depth++ } + elseif ($character -in @(')', ']')) { $depth-- } + elseif ($character -eq ',' -and $depth -eq 0) { $parts.Add($Text.Substring($start, $index - $start).Trim()); $start = $index + 1 } + } + } + if ($quoted -or $depth -ne 0) { throw '[ARM-TEST] Unbalanced expression.' } + if ($Text.Trim()) { $parts.Add($Text.Substring($start).Trim()) } + return ,$parts.ToArray() +} + +function Merge-ArmObjects { + param([Collections.IDictionary] $Left, [Collections.IDictionary] $Right) + $merged = @{} + $Left + foreach ($key in $Right.Keys) { + $merged[$key] = if ($merged.ContainsKey($key) -and $merged[$key] -is [Collections.IDictionary] -and + $Right[$key] -is [Collections.IDictionary]) { Merge-ArmObjects $merged[$key] $Right[$key] } else { $Right[$key] } + } + return $merged +} + +function Resolve-ArmTestValue { + param($Value, [hashtable] $Context, [int] $Depth = 0) + if ($Depth -gt 80) { throw '[ARM-TEST] Cyclic or excessively deep expression.' } + if ($Value -is [Collections.IDictionary]) { + $result = @{} + foreach ($key in $Value.Keys) { $result[$key] = Resolve-ArmTestValue $Value[$key] $Context ($Depth + 1) } + return $result + } + if ($Value -is [Collections.IList]) { + return ,@(foreach ($item in $Value) { Resolve-ArmTestValue $item $Context ($Depth + 1) }) + } + if ($Value -is [string] -and $Value.StartsWith('[') -and $Value.EndsWith(']')) { + return ,(Resolve-ArmTestExpression $Value.Substring(1, $Value.Length - 2) $Context ($Depth + 1)) + } + return ,$Value +} + +function Resolve-ArmTestExpression { + param([string] $Text, [hashtable] $Context, [int] $Depth = 0) + if ($Depth -gt 80) { throw '[ARM-TEST] Cyclic or excessively deep expression.' } + $text = $Text.Trim() + if ($text -match "^'(?:[^']|'')*'$") { return $text.Substring(1, $text.Length - 2).Replace("''", "'") } + if ($text -match '^-?[0-9]+$') { return [int]$text } + if ($text -notmatch '^([a-zA-Z][a-zA-Z0-9]*)\(') { throw "[ARM-TEST] Unsupported expression: $text" } + $name = $Matches[1].ToLowerInvariant() + $open = $text.IndexOf('('); $level = 1; $quoted = $false; $close = -1 + for ($index = $open + 1; $index -lt $text.Length; $index++) { + $character = $text[$index] + if ($character -eq "'") { + if ($quoted -and $index + 1 -lt $text.Length -and $text[$index + 1] -eq "'") { $index++; continue } + $quoted = -not $quoted + } + elseif (-not $quoted) { + if ($character -eq '(') { $level++ } + elseif ($character -eq ')') { $level--; if ($level -eq 0) { $close = $index; break } } + } + } + if ($close -lt 0) { throw '[ARM-TEST] Missing closing parenthesis.' } + $arguments = Split-ArmArguments $text.Substring($open + 1, $close - $open - 1) + if ($name -eq 'if') { + if ($arguments.Count -ne 3) { throw '[ARM-TEST] if requires three arguments.' } + $choice = if (Resolve-ArmTestExpression $arguments[0] $Context ($Depth + 1)) { 1 } else { 2 } + $result = Resolve-ArmTestExpression $arguments[$choice] $Context ($Depth + 1) + } + else { + $values = [Collections.Generic.List[object]]::new() + foreach ($argument in $arguments) { $values.Add((Resolve-ArmTestExpression $argument $Context ($Depth + 1))) } + $result = switch ($name) { + 'parameters' { + if (-not $Context.Parameters.Contains($values[0])) { throw "[ARM-TEST] Unbound parameter '$($values[0])'." } + $Context.Parameters[$values[0]] + } + 'variables' { + if (-not $Context.Template.variables.Contains($values[0])) { throw "[ARM-TEST] Unbound variable '$($values[0])'." } + Resolve-ArmTestValue $Context.Template.variables[$values[0]] $Context ($Depth + 1) + } + 'equals' { $values[0] -ceq $values[1] } + 'not' { -not $values[0] } + 'true' { $true } + 'false' { $false } + 'contains' { $values[0].Contains($values[1]) } + 'json' { ConvertFrom-Json -InputObject $values[0] -AsHashtable } + 'format' { [string]::Format([Globalization.CultureInfo]::InvariantCulture, [string]$values[0], [object[]]$values.ToArray()[1..($values.Count - 1)]) } + 'concat' { $values.ToArray() -join '' } + 'createobject' { + $object = @{} + for ($item = 0; $item -lt $values.Count; $item += 2) { $object[$values[$item]] = $values[$item + 1] } + $object + } + 'union' { + $object = @{} + foreach ($value in $values) { + if ($value -isnot [Collections.IDictionary]) { throw '[ARM-TEST] Only object unions are supported.' } + $object = Merge-ArmObjects $object $value + } + $object + } + 'resourceid' { + $segments = ([string]$values[0]).Split('/') + if ($values.Count -ne $segments.Count) { throw '[ARM-TEST] Unsupported resourceId overload.' } + $id = "/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers/$($segments[0])" + for ($item = 1; $item -lt $segments.Count; $item++) { $id += "/$($segments[$item])/$($values[$item])" } + $id + } + 'subscriptionresourceid' { "/subscriptions/$selectedSubscription/providers/$($values[0])/$($values[1])" } + 'reference' { + if (-not $Context.References.ContainsKey($values[0])) { throw "[ARM-TEST] Unmocked ARM reference '$($values[0])'." } + $Context.ReferenceCalls.Add([string]$values[0]) + $Context.References[$values[0]] + } + 'guid' { + # Assert the exact principal/scope/role passed to ARM's deterministic GUID function, + # rather than duplicating ARM's UUID implementation in the test harness. + $Context.GuidCalls.Add($values.ToArray()) + 'symbolic-arm-guid' + } + default { throw "[ARM-TEST] Unsupported ARM function '$name'." } + } + } + $suffix = $text.Substring($close + 1) + while ($suffix) { + if ($suffix -notmatch '^\.([a-zA-Z_][a-zA-Z0-9_]*)') { throw "[ARM-TEST] Unsupported property selector: $suffix" } + $property = $Matches[1] + if ($result -isnot [Collections.IDictionary] -or -not $result.Contains($property)) { throw "[ARM-TEST] Missing property '$property'." } + $result = $result[$property] + $suffix = $suffix.Substring($Matches[0].Length) + } + return ,$result +} + +function Read-ArmContract { + param([string] $Leaf) + $path = Join-Path (Split-Path -Parent $ScriptAsts[$step2].Extent.File) "arm\$Leaf" + Assert-True (Test-Path -LiteralPath $path -PathType Leaf) "package the separate arm\$Leaf alongside Step2" + $document = [IO.File]::ReadAllText($path) | ConvertFrom-Json -AsHashtable + Assert-Equal $document.'$schema' 'https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#' 'use a standard external ARM deployment template' + return $document +} + +function New-ArmContractContext { + param($Template, [string] $Plan = 'FlexConsumption', [int] $TokenVersion = 1) + $prefix = "/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers" + $values = @{ + functionAppName = 'cyot-offline'; storageAccountName = 'cyotoffline'; keyVaultName = 'cyot-offline-vault' + outboundIdentityName = 'cyot-outbound'; hostingPlanName = 'old-plan'; workspaceName = 'old-workspace'; location = 'westus2' + deployerObjectId = '12121212-1212-4212-8212-121212121212'; tenantId = $customerTenant + applicationId = '33333333-3333-4333-8333-333333333333'; planType = $Plan; tokenVersion = $TokenVersion; enableEasyAuth = $true + tags = @{ Purpose = 'Entra - external = offline'; Owned = 'new' }; deploymentContainerName = 'old-releases'; contentShareName = 'old-content' + existing = @{}; existingAppSettings = @{}; managedSettings = @{} + applicationInsightsResourceId = "$prefix/Microsoft.Insights/components/cyot-offline" + encryptionKeyId = 'abcdefab-1234-4123-8123-abcdefabcdef' + decryptionSecretUri = 'https://cyot-offline-vault.vault.azure.net/secrets/phone-provider-decryption-key' + contentStorageSecretUri = 'https://cyot-offline-vault.vault.azure.net/secrets/phone-provider-content-storage' + } + $parameters = @{} + foreach ($name in $Template.parameters.Keys) { + if ($values.ContainsKey($name)) { $parameters[$name] = $values[$name] } + elseif ($Template.parameters[$name].Contains('defaultValue')) { $parameters[$name] = $Template.parameters[$name].defaultValue } + } + return @{ + Template = $Template; Parameters = $parameters + References = @{ + "$prefix/Microsoft.Web/sites/cyot-offline" = @{ defaultHostName = 'actual-arm-host.example.invalid'; identity = @{ principalId = '66666666-6666-4666-8666-666666666666' } } + "$prefix/Microsoft.Storage/storageAccounts/cyotoffline" = @{ primaryEndpoints = @{ blob = 'https://cyotoffline.blob.core.windows.net/' } } + "$prefix/Microsoft.KeyVault/vaults/cyot-offline-vault" = @{ vaultUri = 'https://cyot-offline-vault.vault.azure.net/' } + "$prefix/Microsoft.ManagedIdentity/userAssignedIdentities/cyot-outbound" = @{ clientId = '88888888-8888-4888-8888-888888888888'; principalId = '99999999-9999-4999-8999-999999999999' } + "$prefix/Microsoft.Insights/components/cyot-offline" = @{ ConnectionString = 'synthetic-server-resolved-insights-connection' } + } + GuidCalls = [Collections.Generic.List[object]]::new(); ReferenceCalls = [Collections.Generic.List[string]]::new() + } +} + +function Get-ArmContractResource { + param($Context, [string] $Type) + $resources = @($Context.Template.resources | Where-Object { + $_.type -eq $Type -and (-not $_.Contains('condition') -or (Resolve-ArmTestValue $_.condition $Context)) + }) + Assert-Equal $resources.Count 1 "exactly one enabled $Type resource" + return $resources[0] +} + +foreach ($expression in @("[invokeExternal('never-run')]", "[reference('https://unmocked.example.invalid')]")) { + Invoke-OfflineTest "ARM expression reader refuses unmocked operations ($expression)" { + $context = New-ArmContractContext @{ parameters = @{}; variables = @{} } + Assert-Throws { Resolve-ArmTestValue $expression $context } -Pattern '\[ARM-TEST\].*(Unsupported|Unmocked)' + } +} + +foreach ($leaf in @('infrastructure.json', 'function-config.json')) { + Invoke-OfflineTest "External ARM parameter/output contract ($leaf)" { + $template = Read-ArmContract $leaf + $types = if ($leaf -eq 'infrastructure.json') { + @{ functionAppName = 'string'; storageAccountName = 'string'; keyVaultName = 'string'; outboundIdentityName = 'string' + hostingPlanName = 'string'; workspaceName = 'string'; location = 'string'; deployerObjectId = 'string'; tenantId = 'string' + applicationId = 'string'; planType = 'string'; tokenVersion = 'int'; enableEasyAuth = 'bool'; tags = 'object' + deploymentContainerName = 'string'; contentShareName = 'string'; existing = 'object' } + } else { + @{ functionAppName = 'string'; planType = 'string'; storageAccountName = 'string'; applicationInsightsResourceId = 'string' + encryptionKeyId = 'string'; decryptionSecretUri = 'string'; contentShareName = 'string'; contentStorageSecretUri = 'string' + managedSettings = 'object'; existingAppSettings = 'secureObject' } + } + Assert-Sequence @($template.parameters.Keys | Sort-Object) @($types.Keys | Sort-Object) 'exact documented parameter surface, without private keys or storage-key inputs' + foreach ($name in $types.Keys) { Assert-True ($template.parameters[$name].type -eq $types[$name]) "ARM type of $name" } + Assert-Sequence $template.parameters.planType.allowedValues @('FlexConsumption', 'Premium') 'supported plan choices' + $outputs = @('functionAppResourceId') + if ($leaf -eq 'infrastructure.json') { + Assert-Sequence $template.parameters.tokenVersion.allowedValues @(1, 2) 'supported token versions' + Assert-True $template.parameters.enableEasyAuth.defaultValue 'Easy Auth is enabled by default' + Assert-Equal $template.parameters.deploymentContainerName.defaultValue 'function-releases' 'default deployment container' + Assert-Equal $template.parameters.contentShareName.defaultValue 'function-content' 'default Premium content share' + $outputs += @('defaultHostName', 'systemAssignedPrincipalId', 'outboundIdentityResourceId', 'outboundIdentityClientId', + 'outboundIdentityPrincipalId', 'storageAccountResourceId', 'keyVaultResourceId', 'keyVaultUri', 'applicationInsightsResourceId', + 'deploymentContainerName', 'contentShareName', 'contentStorageSecretName', 'planType') + } + else { + Assert-Equal $template.parameters.contentShareName.defaultValue '' 'Flex needs no content share' + Assert-Equal $template.parameters.contentStorageSecretUri.defaultValue '' 'Flex needs no content connection secret' + } + Assert-Sequence @($template.outputs.Keys | Sort-Object) @($outputs | Sort-Object) 'only nonsecret identifiers/URIs leave ARM' + foreach ($output in $template.outputs.Values) { Assert-True ($output.type -eq 'string') 'never return settings, keys or secure output objects' } + Assert-True (-not ((ConvertTo-Json -InputObject $template -Depth 100) -match '(?i)listKeys\(|BEGIN PRIVATE KEY|AccountKey=')) 'ARM must not handle plaintext private keys or content-storage keys' + $documents = @($template) + @($template.resources | Where-Object { + $_.type -eq 'Microsoft.Resources/deployments' + } | ForEach-Object { $_.properties.template }) + foreach ($document in $documents) { + if ($document.Contains('variables')) { + Assert-True (-not ((ConvertTo-Json -InputObject $document.variables -Depth 100) -match '(?i)\breference\s*\(')) ( + 'runtime reference() calls belong in resource properties/outputs, never template variables') + } + } + } +} + +foreach ($plan in @('FlexConsumption', 'Premium')) { + Invoke-OfflineTest "ARM infrastructure resources, identity, monitoring and adoption ($plan)" { + $context = New-ArmContractContext (Read-ArmContract 'infrastructure.json') $plan + $existing = @{ tags = @{}; locations = @{ workspace = 'eastus'; vault = 'centralus' }; userAssignedIdentities = @{ unrelated = @{} } + siteConfig = @{ http20Enabled = $true; minTlsVersion = '1.0' }; siteProperties = @{ clientAffinityEnabled = $false; httpsOnly = $false } + storageProperties = @{ publicNetworkAccess = 'Disabled'; allowSharedKeyAccess = $true; supportsHttpsTrafficOnly = $false; allowBlobPublicAccess = $true } + vaultProperties = @{ enablePurgeProtection = $true; enableRbacAuthorization = $false }; planProperties = @{ perSiteScaling = $true } + workspaceProperties = @{ publicNetworkAccessForQuery = 'Disabled' } + insightsProperties = @{ SamplingPercentage = 50; DisableLocalAuth = $false; WorkspaceResourceId = 'wrong-workspace' } } + foreach ($key in @('storage', 'vault', 'plan', 'workspace', 'insights', 'outboundIdentity', 'function')) { + $existing.tags[$key] = @{ UNRELATED = 'keep'; Owned = 'old' } + } + $context.Parameters.existing = $existing + Assert-Equal @($context.Template.resources | Where-Object type -eq 'Microsoft.Web/sites').Count 1 'one top-level Function resource owns the site properties' + foreach ($child in $context.Template.resources | Where-Object type -eq 'Microsoft.Web/sites/config') { + Assert-True (-not (Resolve-ArmTestValue $child.name $context).EndsWith('/appsettings')) 'infrastructure must not emit an appsettings child' + } + foreach ($entry in @{ + 'Microsoft.Storage/storageAccounts' = 'storage'; 'Microsoft.KeyVault/vaults' = 'vault'; 'Microsoft.Web/serverfarms' = 'plan' + 'Microsoft.OperationalInsights/workspaces' = 'workspace'; 'Microsoft.Insights/components' = 'insights' + 'Microsoft.ManagedIdentity/userAssignedIdentities' = 'outboundIdentity'; 'Microsoft.Web/sites' = 'function' + }.GetEnumerator()) { + $resource = Get-ArmContractResource $context $entry.Key + $tags = Resolve-ArmTestValue $resource.tags $context + Assert-Equal $tags.UNRELATED 'keep' 'adopt unrelated tags' + Assert-Equal $tags.Owned 'new' 'managed tags take precedence' + Assert-Equal $tags.Purpose 'Entra - external = offline' 'tag values retain spaces and equals signs in resource JSON' + $expectedLocation = if ($existing.locations.ContainsKey($entry.Value)) { $existing.locations[$entry.Value] } else { $context.Parameters.location } + Assert-Equal (Resolve-ArmTestValue $resource.location $context) $expectedLocation 'preserve an existing per-resource location, otherwise use the supplied default' + } + $storage = Resolve-ArmTestValue (Get-ArmContractResource $context 'Microsoft.Storage/storageAccounts').properties $context + Assert-True $storage.supportsHttpsTrafficOnly 'HTTPS-only storage' + Assert-Equal $storage.minimumTlsVersion 'TLS1_2' 'minimum storage TLS' + Assert-True (-not $storage.allowBlobPublicAccess) 'no public blobs' + Assert-Equal $storage.publicNetworkAccess 'Disabled' 'retain supported storage network settings' + $vault = Resolve-ArmTestValue (Get-ArmContractResource $context 'Microsoft.KeyVault/vaults').properties $context + Assert-True $vault.enableRbacAuthorization 'vault uses Azure RBAC' + Assert-Equal $vault.tenantId $customerTenant 'vault belongs to the customer tenant' + Assert-True $vault.enablePurgeProtection 'retain existing purge protection' + $insights = Resolve-ArmTestValue (Get-ArmContractResource $context 'Microsoft.Insights/components').properties $context + Assert-True ($insights.DisableLocalAuth -is [bool] -and $insights.DisableLocalAuth) 'Application Insights disables local-key authentication' + Assert-Equal $insights.Application_Type 'web' 'web Application Insights component' + Assert-True $insights.WorkspaceResourceId.EndsWith('/old-workspace') 'link the resolved/adopted workspace' + Assert-Equal $insights.SamplingPercentage 50 'retain supported monitoring customization' + $site = Get-ArmContractResource $context 'Microsoft.Web/sites' + $properties = Resolve-ArmTestValue $site.properties $context + $identity = Resolve-ArmTestValue $site.identity $context + Assert-True ($identity.type -match 'SystemAssigned' -and $identity.type -match 'UserAssigned') 'both system and outbound managed identities' + Assert-True $identity.userAssignedIdentities.Contains('unrelated') 'retain unrelated identities' + Assert-True (@($identity.userAssignedIdentities.Keys | Where-Object { $_ -like '*/cyot-outbound' }).Count -eq 1) 'attach the outbound identity' + Assert-True $properties.httpsOnly 'Function requires HTTPS' + Assert-Equal $properties.keyVaultReferenceIdentity 'SystemAssigned' 'private-key references use the system identity' + Assert-True $properties.siteConfig.http20Enabled 'retain supported existing siteConfig' + Assert-Equal $properties.siteConfig.minTlsVersion '1.2' 'required TLS settings override the adopted snapshot' + Assert-True (-not $properties.clientAffinityEnabled) 'retain supported site properties' + Assert-True (-not $properties.siteConfig.Contains('appSettings')) 'infra must not race/replace configuration-phase settings' + $sku = Resolve-ArmTestValue (Get-ArmContractResource $context 'Microsoft.Web/serverfarms').sku $context + Assert-Equal $sku.name $(if ($plan -eq 'Premium') { 'EP1' } else { 'FC1' }) 'correct hosting SKU' + if ($plan -eq 'FlexConsumption') { + $container = Get-ArmContractResource $context 'Microsoft.Storage/storageAccounts/blobServices/containers' + Assert-True (Resolve-ArmTestValue $container.name $context).EndsWith('/old-releases') 'adopt the existing deployment container name' + Assert-Equal $container.properties.publicAccess 'None' 'private deployment container' + Assert-Equal $properties.functionAppConfig.runtime.name 'node' 'Flex runtime' + Assert-Equal $properties.functionAppConfig.runtime.version '24' 'Flex Node 24' + Assert-Equal $properties.functionAppConfig.scaleAndConcurrency.alwaysReady[0].name 'http' 'always-ready HTTP group' + Assert-Equal $properties.functionAppConfig.scaleAndConcurrency.alwaysReady[0].instanceCount 1 'one always-ready HTTP instance' + Assert-Equal $properties.functionAppConfig.deployment.storage.authentication.type 'SystemAssignedIdentity' 'identity-based deployment storage' + } + else { + Assert-True (-not $properties.Contains('functionAppConfig')) 'Premium must not receive Flex functionAppConfig' + $share = Get-ArmContractResource $context 'Microsoft.Storage/storageAccounts/fileServices/shares' + Assert-True (Resolve-ArmTestValue $share.name $context).EndsWith('/old-content') 'precreate the adopted Premium content share' + Assert-Equal $properties.siteConfig.linuxFxVersion 'NODE|24' 'Premium Node 24' + } + $outputs = @{} + foreach ($name in $context.Template.outputs.Keys) { $outputs[$name] = Resolve-ArmTestValue $context.Template.outputs[$name].value $context } + Assert-Equal $outputs.defaultHostName 'actual-arm-host.example.invalid' 'return the actual host from reference(), not a guessed hostname' + Assert-Equal $outputs.systemAssignedPrincipalId '66666666-6666-4666-8666-666666666666' 'return the created Function principal' + Assert-Equal $outputs.outboundIdentityClientId '88888888-8888-4888-8888-888888888888' 'return the actual outbound client ID' + Assert-Equal $outputs.outboundIdentityPrincipalId '99999999-9999-4999-8999-999999999999' 'return the actual outbound principal' + Assert-Equal $outputs.contentShareName $(if ($plan -eq 'Premium') { 'old-content' } else { '' }) 'no content-share setting for Flex' + Assert-Equal $outputs.contentStorageSecretName $(if ($plan -eq 'Premium') { 'phone-provider-content-storage' } else { '' }) 'only Premium requests the content secret' + } + foreach ($version in @(1, 2)) { + Invoke-OfflineTest "ARM Easy Auth is fail-closed and pins issuer/audience/caller ($plan token v$version)" { + $context = New-ArmContractContext (Read-ArmContract 'infrastructure.json') $plan $version + $auth = Get-ArmContractResource $context 'Microsoft.Web/sites/config' + Assert-True (Resolve-ArmTestValue $auth.name $context).EndsWith('/authsettingsV2') 'configure Easy Auth, not application auth fallback' + $properties = Resolve-ArmTestValue $auth.properties $context + Assert-True $properties.platform.enabled 'enable Easy Auth' + Assert-True $properties.globalValidation.requireAuthentication 'all requests require authentication' + Assert-Equal $properties.globalValidation.unauthenticatedClientAction 'Return401' 'unauthenticated requests fail closed' + Assert-Equal @($properties.globalValidation.excludedPaths).Count 0 'no unauthenticated path exclusions' + Assert-True $properties.httpSettings.requireHttps 'require HTTPS at the authentication boundary' + $aad = $properties.identityProviders.azureActiveDirectory + Assert-Equal $aad.registration.clientId $context.Parameters.applicationId 'pin the registered endpoint application' + Assert-Equal $aad.registration.openIdIssuer $(if ($version -eq 1) { "https://sts.windows.net/$customerTenant/" } else { "https://login.microsoftonline.com/$customerTenant/v2.0" }) 'explicit customer-tenant issuer' + Assert-Sequence $aad.validation.allowedAudiences @($(if ($version -eq 1) { "api://actual-arm-host.example.invalid/$($context.Parameters.applicationId)" } else { $context.Parameters.applicationId })) 'exact endpoint audience' + Assert-Sequence $aad.validation.defaultAuthorizationPolicy.allowedApplications @('25ec60fa-f18d-41a4-b398-50044c90ce13') 'nonempty fixed Microsoft EPP caller allowlist' + $context.Parameters.enableEasyAuth = $false + Assert-True (-not (Resolve-ArmTestValue $auth.condition $context)) 'the explicit opt-out skips the resource instead of disabling existing auth' + } + } + Invoke-OfflineTest "ARM appsettings merge keeps unrelated values but required host settings win ($plan)" { + $context = New-ArmContractContext (Read-ArmContract 'function-config.json') $plan + Assert-Equal $context.Template.resources.Count 1 'configuration phase only writes the appsettings child resource' + $resource = Get-ArmContractResource $context 'Microsoft.Web/sites/config' + Assert-True (Resolve-ArmTestValue $resource.name $context).EndsWith('/appsettings') 'only the appsettings child is configured' + $context.Parameters.existingAppSettings = @{ USER_KEEP = 'preserved-private-fixture'; OVERLAP = 'existing' + AzureWebJobsStorage__accountName = 'wrong-storage'; APPLICATIONINSIGHTS_CONNECTION_STRING = 'wrong-existing-insights' } + $context.Parameters.managedSettings = @{ OVERLAP = 'managed'; EPP_PROVIDER_NAME = 'provider' + AzureWebJobsStorage__accountName = 'wrong-managed-storage'; AzureWebJobsStorage__credential = 'wrong-credential' + APPLICATIONINSIGHTS_CONNECTION_STRING = 'wrong-managed-insights'; APPLICATIONINSIGHTS_AUTHENTICATION_STRING = 'wrong-auth' + EPP_ENCRYPTION_KEY_ID = 'wrong-key-id'; EPP_DECRYPTION_KEY_PEM = 'wrong-private-key-reference' } + if ($plan -eq 'Premium') { + foreach ($name in $flexObsolete) { + $context.Parameters.existingAppSettings[$name] = 'wrong-existing-host-value' + $context.Parameters.managedSettings[$name] = 'wrong-managed-host-value' + } + } + $settings = Resolve-ArmTestValue $resource.properties $context + Assert-Equal $settings.USER_KEEP 'preserved-private-fixture' 'retain unrelated settings without logging or outputting them' + Assert-Equal $settings.OVERLAP 'managed' 'managed settings override the matching existing value' + Assert-Equal $settings.AzureWebJobsStorage__accountName 'cyotoffline' 'required storage settings override both inputs' + Assert-Equal $settings.AzureWebJobsStorage__credential 'managedidentity' 'identity-based host storage' + Assert-Equal $settings.APPLICATIONINSIGHTS_CONNECTION_STRING 'synthetic-server-resolved-insights-connection' 'resolve Insights server-side' + Assert-True $context.ReferenceCalls.Contains($context.Parameters.applicationInsightsResourceId) 'connection string comes from a reference(), not a parameter' + Assert-Equal $settings.APPLICATIONINSIGHTS_AUTHENTICATION_STRING 'Authorization=AAD' 'use AAD telemetry authentication' + Assert-Equal $settings.EPP_ENCRYPTION_KEY_ID $context.Parameters.encryptionKeyId 'publish the actual encryption key ID' + Assert-Equal $settings.EPP_DECRYPTION_KEY_PEM "@Microsoft.KeyVault(SecretUri=$($context.Parameters.decryptionSecretUri))" 'private-key value is only a Key Vault reference' + foreach ($name in $commonObsolete) { Assert-True (-not $settings.ContainsKey($name)) "do not add obsolete $name" } + if ($plan -eq 'FlexConsumption') { + foreach ($name in $flexObsolete) { Assert-True (-not $settings.ContainsKey($name)) "Flex must not receive legacy $name" } + } + else { + Assert-Equal $settings.FUNCTIONS_WORKER_RUNTIME 'node' 'Premium Node worker' + Assert-Equal $settings.FUNCTIONS_EXTENSION_VERSION.TrimStart('~') '4' 'Premium Functions v4' + Assert-Equal $settings.WEBSITE_NODE_DEFAULT_VERSION.TrimStart('~') '24' 'Premium Node 24' + Assert-Equal $settings.WEBSITE_RUN_FROM_PACKAGE '1' 'Premium package deployment' + Assert-Equal $settings.WEBSITE_CONTENTSHARE 'old-content' 'use the precreated content share' + Assert-Equal $settings.WEBSITE_CONTENTAZUREFILECONNECTIONSTRING "@Microsoft.KeyVault(SecretUri=$($context.Parameters.contentStorageSecretUri))" 'content storage uses only a Key Vault connection-string reference' + Assert-Equal $settings.WEBSITE_SKIP_CONTENTSHARE_VALIDATION '1' 'avoid plaintext-key validation when referencing the precreated share' + } + foreach ($value in $settings.Values) { Assert-True ($value -is [string]) 'application settings are string-valued' } + } +} + +foreach ($adopt in @($false, $true)) { + Invoke-OfflineTest "ARM creates six scoped least-privilege roles using actual principals or adopted names (adopt=$adopt)" { + $context = New-ArmContractContext (Read-ArmContract 'infrastructure.json') + $roles = @{ + storageBlob = @('Microsoft.Storage/storageAccounts/cyotoffline', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe', 'ServicePrincipal') + storageQueue = @('Microsoft.Storage/storageAccounts/cyotoffline', '974c5e8b-45b9-4653-ba55-5f855dd0fb88', 'ServicePrincipal') + storageTable = @('Microsoft.Storage/storageAccounts/cyotoffline', '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3', 'ServicePrincipal') + vaultReader = @('Microsoft.KeyVault/vaults/cyot-offline-vault', '4633458b-17de-408a-b874-0445c86b69e6', 'ServicePrincipal') + vaultWriter = @('Microsoft.KeyVault/vaults/cyot-offline-vault', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7', 'User') + metricsPublisher = @('Microsoft.Insights/components/cyot-offline', '3913510d-42f4-4e42-8a64-420c390055eb', 'ServicePrincipal') + } + $oldNames = @{} + $index = 0 + foreach ($key in $roles.Keys | Sort-Object) { $index++; $oldNames[$key] = "77777777-7777-4777-8777-$('{0:d12}' -f $index)" } + $context.Parameters.existing = @{ roleAssignmentNames = $(if ($adopt) { $oldNames } else { @{} }) } + $deployment = Get-ArmContractResource $context 'Microsoft.Resources/deployments' + Assert-Equal $deployment.properties.mode 'Incremental' 'nested RBAC deployment must be incremental' + Assert-True ($deployment.properties.expressionEvaluationOptions.scope -eq 'inner') 'bind runtime identities inside the nested deployment (ARM enum casing is not significant)' + $nested = @{ + Template = $deployment.properties.template + Parameters = @{}; References = $context.References + GuidCalls = [Collections.Generic.List[object]]::new(); ReferenceCalls = [Collections.Generic.List[string]]::new() + } + foreach ($name in $deployment.properties.parameters.Keys) { $nested.Parameters[$name] = Resolve-ArmTestValue $deployment.properties.parameters[$name].value $context } + Assert-Equal $nested.Template.resources.Count 6 'exactly six least-privilege role assignments' + $seenRoles = @() + foreach ($resource in $nested.Template.resources) { + Assert-Equal $resource.type 'Microsoft.Authorization/roleAssignments' 'all nested resources are scoped roles' + $properties = Resolve-ArmTestValue $resource.properties $nested + $scope = Resolve-ArmTestValue $resource.scope $nested + $roleId = ($properties.roleDefinitionId -split '/')[-1] + $keys = @($roles.Keys | Where-Object { $roles[$_][1] -eq $roleId }) + Assert-Equal $keys.Count 1 'no broad, extra or unknown role is granted' + $key = $keys[0]; $seenRoles += $roleId + Assert-Equal $scope $roles[$key][0] 'scope assignments to the exact storage/vault/Insights resource' + Assert-Equal $properties.principalType $roles[$key][2] 'use the proper principal type' + $principal = if ($key -eq 'vaultWriter') { $context.Parameters.deployerObjectId } else { '66666666-6666-4666-8666-666666666666' } + Assert-Equal $properties.principalId $principal 'use the actual deployed Function principal or selected deployer' + $name = Resolve-ArmTestValue $resource.name $nested + if ($adopt) { Assert-Equal $name $oldNames[$key] 'keep the old assignment name rather than creating a duplicate' } + else { + $guidInputs = $nested.GuidCalls[-1] + Assert-Equal $guidInputs.Count 3 'new assignment GUID is based on scope, principal and role only' + foreach ($expected in @("/subscriptions/$selectedSubscription/resourceGroups/cyot-offline-rg/providers/$scope", $principal, $roleId)) { + Assert-True ($guidInputs -contains $expected) 'GUID inputs must include actual scope, actual principal and exact role' + } + } + } + Assert-Equal @($seenRoles | Select-Object -Unique).Count 6 'no role is duplicated in place of another' + Assert-Equal $nested.GuidCalls.Count $(if ($adopt) { 0 } else { 6 }) 'adopt old IDs without evaluating the new-ID branch' + } +} diff --git a/tests/setup/Test-CyotBehavior.ps1 b/tests/setup/Test-CyotBehavior.ps1 new file mode 100644 index 0000000..4718a20 --- /dev/null +++ b/tests/setup/Test-CyotBehavior.ps1 @@ -0,0 +1,880 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [hashtable] $ScriptAsts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Function AST extents are loaded without script entry points or #Requires directives. +# The bounded Stage1 reuse tests also extract non-function statements into the mocked module. +# Every scenario gets its own in-memory module. Unknown commands are denied before loading; +# known service/input commands are throwing guards unless that scenario explicitly mocks them. +$step1 = 'Step1-Register-CyotApplication.ps1' +$step2 = 'Step2-Setup-ExternalPhoneProvider.ps1' +$step3 = 'Step3-Set-CyotPolicy.ps1' +$customerTenant = '11111111-1111-4111-8111-111111111111' +$otherTenant = '22222222-2222-4222-8222-222222222222' +$firstSubscription = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +$selectedSubscription = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +$foreignSubscription = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +$disabledSubscription = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' +$unknownSubscription = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' +$script:passed = 0 +$script:failures = [Collections.Generic.List[string]]::new() +$script:activeModules = [Collections.Generic.List[object]]::new() + +function Assert-True { + param([bool] $Condition, [string] $Because) + if (-not $Condition) { throw "Assertion failed: $Because" } +} + +function Assert-Equal { + param($Actual, $Expected, [string] $Because) + if ($Actual -cne $Expected) { + throw "Assertion failed: $Because. Expected <$Expected>; got <$Actual>." + } +} + +function Assert-Sequence { + param([object[]] $Actual, [object[]] $Expected, [string] $Because) + Assert-Equal $Actual.Count $Expected.Count "$Because (count)" + for ($index = 0; $index -lt $Expected.Count; $index++) { + Assert-Equal $Actual[$index] $Expected[$index] "$Because (index $index)" + } +} + +function Assert-Throws { + param([scriptblock] $Action, [string] $Pattern = '.', [string] $Because = 'the operation must fail', [switch] $PassThru) + $caught = $null + try { & $Action | Out-Null } + catch { $caught = $_ } + Assert-True ($null -ne $caught) $Because + if ($caught.Exception.Message -match '\[OFFLINE-GUARD\]') { throw $caught } + Assert-True ($caught.Exception.Message -match $Pattern) ( + "$Because; expected error matching '$Pattern', got '$($caught.Exception.Message)'") + if ($PassThru) { return $caught } +} + +function Invoke-OfflineTest { + param([string] $Name, [scriptblock] $Body) + try { + & $Body | Out-Null + foreach ($module in $script:activeModules) { + & $module { + if ($script:TestState.UnexpectedCalls.Count) { + throw "[OFFLINE-GUARD] A function swallowed an unmocked call: $($script:TestState.UnexpectedCalls -join ', ')." + } + } + } + $script:passed++ + Write-Host "PASS $Name" + } + catch { + $script:failures.Add("$Name`: $($_.Exception.Message)") + Write-Host "FAIL $Name`: $($_.Exception.Message)" -ForegroundColor Red + } + finally { + foreach ($module in $script:activeModules) { + Remove-Module -ModuleInfo $module -Force -ErrorAction SilentlyContinue + } + $script:activeModules.Clear() + } +} + +function Get-FunctionAst { + param([string] $ScriptName, [string] $Name) + $matches = @($ScriptAsts[$ScriptName].EndBlock.Statements | Where-Object { + $_ -is [Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -eq $Name + }) + Assert-Equal $matches.Count 1 "$ScriptName must define exactly one $Name function" + return $matches[0] +} + +function New-OfflineModule { + param( + [string] $ScriptName, + [string[]] $Functions, + [hashtable] $Variables = @{}, + [hashtable] $State = @{}, + [hashtable] $Mocks = @{}, + [switch] $IncludeStage1ReuseFlow + ) + + $safeCommands = @( + 'ConvertFrom-Json', 'ConvertTo-Json', 'Where-Object', 'ForEach-Object', + 'Select-Object', 'Format-Table', 'Out-Null', 'Out-Host', 'Write-Host', + 'Write-Warning', 'Join-Path', 'Split-Path', 'Resolve-Path', 'Test-Path', 'Set-StrictMode' + ) + $guardedCommands = @( + 'az', 'Read-Host', 'Invoke-WebRequest', 'Invoke-RestMethod', 'Remove-Item', 'Get-Command', 'Start-Sleep' + ) + $localFunctions = @($ScriptAsts[$ScriptName].EndBlock.Statements | Where-Object { + $_ -is [Management.Automation.Language.FunctionDefinitionAst] + } | ForEach-Object Name) + $functionAsts = @(foreach ($name in $Functions) { Get-FunctionAst $ScriptName $name }) + $reuseStatements = @() + if ($IncludeStage1ReuseFlow) { + Assert-Equal $ScriptName $step1 'only the bounded Stage1 reuse flow may load non-function statements' + foreach ($parameter in $ScriptAsts[$ScriptName].ParamBlock.Parameters) { + $name = $parameter.Name.VariablePath.UserPath + Assert-True ($Variables.ContainsKey($name)) "supply Stage1 parameter variable $name explicitly" + } + Assert-True ($Variables.NonInteractive -eq $true) 'Stage1 reuse must never prompt for sign-in or approval' + foreach ($name in @('Get-MgContext', 'Get-MgApplication', 'Get-MgServicePrincipal')) { + Assert-True ($Mocks.ContainsKey($name)) "Stage1 reuse requires an explicit $name mock" + } + $reuseStatements = @($ScriptAsts[$ScriptName].EndBlock.Statements | Where-Object { + $_ -isnot [Management.Automation.Language.FunctionDefinitionAst] + }) + Assert-True ($reuseStatements.Count -gt 0) 'extract the Stage1 statements, not a substitute test implementation' + } + foreach ($node in @($functionAsts) + $reuseStatements) { + $description = "$ScriptName line $($node.Extent.StartLineNumber)" + foreach ($command in $node.FindAll({ + param($node) + $node -is [Management.Automation.Language.CommandAst] + }, $true)) { + $commandName = $command.GetCommandName() + if (-not $commandName -or $command.InvocationOperator -eq + [Management.Automation.Language.TokenKind]::Dot) { + throw "[OFFLINE-GUARD] Dynamic/script invocation in $description is not allowed in offline tests." + } + if ($commandName -in $safeCommands -or $commandName -in $Functions) { continue } + if ($commandName -notin $localFunctions -and $commandName -notin $guardedCommands -and + $commandName -notmatch '^[A-Za-z]+-Mg[A-Za-z0-9]+$') { + throw "[OFFLINE-GUARD] Unapproved command '$commandName' in $description." + } + $guardedCommands += $commandName + } + } + $State.UnexpectedCalls = [Collections.Generic.List[string]]::new() + $State.Messages = [Collections.Generic.List[string]]::new() + $State.Displayed = [Collections.Generic.List[object]]::new() + $configuration = @{ + Definitions = @($functionAsts | ForEach-Object { $_.Extent.Text }) + ReuseStatements = ($reuseStatements | ForEach-Object { $_.Extent.Text }) -join "`n" + Guards = @($guardedCommands | Select-Object -Unique) + Variables = $Variables + State = $State + Mocks = $Mocks + FixtureDirectory = $script:fixtureDirectory + } + $module = New-Module -Name "CyotOffline_$([Guid]::NewGuid().ToString('N'))" -ArgumentList $configuration -ScriptBlock { + param($Configuration) + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $PSModuleAutoLoadingPreference = 'None' + $script:TestState = $Configuration.State + $script:NonInteractive = $true + $script:TenantId = $null + $script:SubscriptionId = $null + $script:GraphTenantId = $null + $script:AzureCliContext = $null + $script:FixtureDirectory = $Configuration.FixtureDirectory + foreach ($entry in $Configuration.Variables.GetEnumerator()) { + Set-Variable -Name $entry.Key -Value $entry.Value -Scope Script + } + function Stop-UnmockedCall { + param([string] $Name) + $script:TestState.UnexpectedCalls.Add($Name) + throw "[OFFLINE-GUARD] Unmocked call: $Name" + } + $guard = { Stop-UnmockedCall $MyInvocation.MyCommand.Name } + foreach ($name in $Configuration.Guards) { + Set-Item -LiteralPath "Function:script:$name" -Value $guard + } + function Write-Host { + param([object[]] $Object, $ForegroundColor, $BackgroundColor, [switch] $NoNewline) + $script:TestState.Messages.Add(($Object -join ' ')) + } + function Write-Warning { + param([string] $Message) + $script:TestState.Messages.Add($Message) + } + function Format-Table { + param([Parameter(ValueFromPipeline)] $InputObject, [switch] $AutoSize) + process { $InputObject } + } + function Out-Host { + param([Parameter(ValueFromPipeline)] $InputObject) + process { $script:TestState.Displayed.Add($InputObject) } + } + function Remove-Item { + [CmdletBinding()] + param([string] $LiteralPath, [switch] $Force) + if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($LiteralPath)) -ne $script:FixtureDirectory) { + Stop-UnmockedCall "Cleanup outside the fixture directory: $LiteralPath" + } + Microsoft.PowerShell.Management\Remove-Item @PSBoundParameters + } + foreach ($definition in $Configuration.Definitions) { + . ([scriptblock]::Create($definition)) + } + if ($Configuration.Mocks.ContainsKey('Read-SetupValue')) { + $script:OriginalReadSetupValue = (Get-Item -LiteralPath Function:Read-SetupValue).ScriptBlock + } + foreach ($entry in $Configuration.Mocks.GetEnumerator()) { + # Recreate mocks here so $script: refers to this module, not the test runner. + Set-Item -LiteralPath "Function:script:$($entry.Key)" -Value ([scriptblock]::Create($entry.Value.ToString())) + } + if ($Configuration.ReuseStatements) { + $script:Stage1ReuseBody = [scriptblock]::Create($Configuration.ReuseStatements) + } + Export-ModuleMember -Function @() -Alias @() -Variable @() + } + $script:activeModules.Add($module) + return $module +} + +function Get-CliOption { + param([string[]] $Arguments, [string[]] $Names) + foreach ($name in $Names) { + $index = [Array]::IndexOf($Arguments, $name) + if ($index -ge 0 -and $index + 1 -lt $Arguments.Count) { return $Arguments[$index + 1] } + } + return $null +} + +function New-Subscription { + param([string] $Id, [string] $Tenant = $customerTenant, [string] $State = 'Enabled') + return [pscustomobject]@{ + id = $Id; tenantId = $Tenant; state = $State + name = "Synthetic subscription $Id" + isDefault = ($Id -eq $firstSubscription) + user = [pscustomobject]@{ type = 'user'; name = 'operator@example.invalid' } + } +} + +function New-CliScenario { + param( + [AllowNull()] $Tenant = $customerTenant, + [AllowNull()] $Subscription = $selectedSubscription, + [bool] $NonInteractive = $false, + [object[]] $Subscriptions = @( + (New-Subscription $foreignSubscription $otherTenant) + (New-Subscription $disabledSubscription $customerTenant 'Disabled') + (New-Subscription $firstSubscription) + (New-Subscription $selectedSubscription) + ), + $Account = (New-Subscription $selectedSubscription), + [string[]] $Answers = @(), + [string] $FailAt = '' + ) + $state = @{ + Calls = [Collections.Generic.List[object]]::new() + Reads = [Collections.Generic.List[object]]::new() + Prompts = [Collections.Generic.List[string]]::new() + Answers = [Collections.Generic.Queue[string]]::new() + Subscriptions = $Subscriptions + Account = $Account + FailAt = $FailAt + FailureMessage = 'Injected CLI authentication failure: AADSTS50076' + } + foreach ($answer in $Answers) { $state.Answers.Enqueue($answer) } + $module = New-OfflineModule -ScriptName $step2 -Functions @( + 'Initialize-AzureCliAuthentication', 'Read-SetupValue', 'Assert-AzCommandSucceeded', + 'Invoke-AzResult', 'Invoke-Az' + ) -Variables @{ + TenantId = $Tenant; SubscriptionId = $Subscription; NonInteractive = $NonInteractive + GraphTenantId = $otherTenant + } -State $state -Mocks @{ + 'Read-SetupValue' = { + param([string] $Name, $DefaultValue, [switch] $Required, [string] $ValueType = 'String', + [string[]] $Choices = @(), [string] $Hint) + $script:TestState.Reads.Add([pscustomobject]@{ + Name = $Name; DefaultValue = $DefaultValue; Required = [bool]$Required + ValueType = $ValueType; Choices = $Choices + }) + & $script:OriginalReadSetupValue @PSBoundParameters + } + 'Read-Host' = { + param([string] $Prompt) + $script:TestState.Prompts.Add($Prompt) + if (-not $script:TestState.Answers.Count) { Stop-UnmockedCall "Unexpected prompt: $Prompt" } + $script:TestState.Answers.Dequeue() + } + 'Invoke-AzCommand' = { + param([string[]] $Arguments, [switch] $Interactive) + $operation = if ($Arguments[0] -eq 'login') { 'login' } + else { $Arguments[0..1] -join ' ' } + $script:TestState.Calls.Add([pscustomobject]@{ + Operation = $operation; Arguments = $Arguments; Interactive = [bool]$Interactive + PublishedContext = $script:AzureCliContext; PublishedTenant = $script:GraphTenantId + }) + if ($operation -eq $script:TestState.FailAt) { + return [pscustomobject]@{ ExitCode = 71; Lines = @($script:TestState.FailureMessage) } + } + $lines = switch ($operation) { + 'login' { @() } + 'account list' { ConvertTo-Json -InputObject $script:TestState.Subscriptions -Depth 5 -Compress } + 'account show' { ConvertTo-Json -InputObject $script:TestState.Account -Depth 5 -Compress } + 'account set' { @() } + 'resource list' { '[]' } + 'ad signed-in-user' { '{"id":"synthetic-user"}' } + default { Stop-UnmockedCall "az $($Arguments -join ' ')" } + } + [pscustomobject]@{ ExitCode = 0; Lines = @($lines) } + } + } + return @{ Module = $module; State = $state } +} + +function Assert-CliNotPublished { + param($Scenario) + $context = & $Scenario.Module { $script:AzureCliContext } + $tenant = & $Scenario.Module { $script:GraphTenantId } + Assert-True ($null -eq $context) 'failed initialization must not publish an Azure context' + Assert-Equal $tenant $otherTenant 'failed initialization must not publish a Graph tenant' + foreach ($call in $Scenario.State.Calls) { + Assert-True ($null -eq $call.PublishedContext) 'Azure context is published only after account set succeeds' + Assert-Equal $call.PublishedTenant $otherTenant 'Graph tenant is published only after account set succeeds' + } +} + +function Assert-CliSelection { + param($Scenario, [string] $Selected = $selectedSubscription, [bool] $Interactive = $true) + $calls = $Scenario.State.Calls + $expected = @('account list', 'account show', 'account set') + if ($Interactive) { $expected = @('login') + $expected } + Assert-Sequence @($calls | ForEach-Object Operation) $expected 'CLI ordering (no cached account show, preflight or retry)' + $offset = 0 + if ($Interactive) { + Assert-Sequence $calls[0].Arguments @( + 'login', '--tenant', $customerTenant, '--output', 'none', '--only-show-errors' + ) 'login must explicitly target the customer tenant' + Assert-True $calls[0].Interactive 'login must preserve interactive sign-in' + $offset = 1 + } + Assert-True ($calls[$offset].Arguments -contains '--all') 'account list must include all accessible subscriptions' + Assert-Equal (Get-CliOption $calls[$offset].Arguments @('--output', '-o')) 'json' 'account list must return JSON' + Assert-Equal (Get-CliOption $calls[$offset + 1].Arguments @('--subscription')) $Selected 'account show must target the selection' + Assert-Equal (Get-CliOption $calls[$offset + 1].Arguments @('--output', '-o')) 'json' 'account show must return JSON' + Assert-Equal (Get-CliOption $calls[$offset + 2].Arguments @('--subscription')) $Selected 'account set must target the verified selection' + foreach ($call in $calls | Select-Object -Skip $offset) { + Assert-True (-not $call.Interactive) 'only login is an interactive CLI command' + } + foreach ($call in $calls) { + Assert-True ($null -eq $call.PublishedContext) 'context must not be published before account set returns' + Assert-Equal $call.PublishedTenant $otherTenant 'tenant must not be published before account set returns' + } + $context = & $Scenario.Module { $script:AzureCliContext } + Assert-Equal $context.id $Selected 'publish the selected subscription' + Assert-Equal $context.tenantId $customerTenant 'publish the verified customer tenant' + Assert-Equal (& $Scenario.Module { $script:GraphTenantId }) $customerTenant 'pin Graph to the verified tenant' + Assert-Equal $Scenario.State.Reads[0].Name 'TenantId' 'validate the customer tenant first' + Assert-Equal $Scenario.State.Reads[0].ValueType 'Guid' 'customer tenant validation uses GUIDs' + Assert-True $Scenario.State.Reads[0].Required 'customer tenant is required' + Assert-Equal $Scenario.State.Reads[1].Name 'SubscriptionId' 'validate the optional subscription' + Assert-Equal $Scenario.State.Reads[1].ValueType 'Guid' 'supplied subscription validation uses GUIDs' +} + +function New-GraphContext { + param([string[]] $Scopes, [string] $Variant = 'Valid') + if ($Variant -eq 'Missing') { return $null } + $context = [pscustomobject]@{ + TenantId = $customerTenant; AuthType = 'Delegated'; Environment = 'Global' + TokenCredentialType = 'InteractiveBrowser'; Scopes = @($Scopes) + @('User.Read') + } + switch ($Variant) { + 'WrongTenant' { $context.TenantId = $otherTenant } + 'WrongEnvironment' { $context.Environment = 'USGov' } + 'AppOnly' { $context.AuthType = 'AppOnly' } + 'MissingScope' { $context.Scopes = @('User.Read') } + 'ManualToken' { $context.TokenCredentialType = 'UserProvidedAccessToken' } + } + return $context +} + +function New-GraphScenario { + param( + [string] $ScriptName, $Before, $After, [bool] $NonInteractive = $false, + [AllowNull()] $Tenant = $customerTenant, [string] $ConnectError = '', [string] $ContextError = '' + ) + $state = @{ + Before = $Before; After = $After; Reads = 0 + Connections = [Collections.Generic.List[object]]::new() + ConnectError = $ConnectError; ContextError = $ContextError + } + $module = New-OfflineModule -ScriptName $ScriptName -Functions @( + 'Read-SetupValue', 'Connect-EndpointGraph' + ) -Variables @{ GraphTenantId = $Tenant; NonInteractive = $NonInteractive } -State $state -Mocks @{ + 'Get-MgContext' = { + [CmdletBinding()] + param() + $script:TestState.Reads++ + if ($script:TestState.ContextError) { throw $script:TestState.ContextError } + if ($script:TestState.Connections.Count) { return $script:TestState.After } + return $script:TestState.Before + } + 'Connect-MgGraph' = { + [CmdletBinding()] + param([string] $TenantId, [string[]] $Scopes, [string] $ContextScope, + [string] $Environment, [switch] $NoWelcome) + $script:TestState.Connections.Add([pscustomobject]@{ + TenantId = $TenantId; Scopes = $Scopes; ContextScope = $ContextScope; Environment = $Environment + }) + if ($script:TestState.ConnectError) { throw $script:TestState.ConnectError } + } + } + return @{ Module = $module; State = $state } +} + +function Assert-GraphConnection { + param($Scenario, [string[]] $Scopes) + Assert-Equal $Scenario.State.Connections.Count 1 'connect exactly once, without retries or tenant fallback' + $connection = $Scenario.State.Connections[0] + Assert-Equal $connection.TenantId $customerTenant 'Graph sign-in must pin the customer tenant' + Assert-Equal $connection.ContextScope 'Process' 'Graph sign-in must use process context' + Assert-Equal $connection.Environment 'Global' 'Graph sign-in must use Global' + Assert-Sequence $connection.Scopes $Scopes 'forward every requested scope' + Assert-Equal $Scenario.State.Reads 2 'verify context again after sign-in' + Assert-Equal (& $Scenario.Module { $script:GraphTenantId }) $customerTenant 'never replace the selected tenant' +} + +function New-Stage1ReuseScenario { + param([ValidateSet('ClientId', 'DisplayName')] [string] $Lookup) + $application = [pscustomobject]@{ + Id = '44444444-4444-4444-8444-444444444444' + AppId = '33333333-3333-4333-8333-333333333333' + DisplayName = "Synthetic operator's CYOT" + SignInAudience = 'AzureADMultipleOrgs' + } + $state = @{ + Application = $application + Principal = [pscustomobject]@{ + Id = '55555555-5555-4555-8555-555555555555' + AppId = $application.AppId + AppRoleAssignmentRequired = $false + } + Context = New-GraphContext @('Application.ReadWrite.All') + Calls = [Collections.Generic.List[string]]::new() + } + $module = New-OfflineModule -ScriptName $step1 -IncludeStage1ReuseFlow -Functions @( + 'Write-Step', 'Read-SetupValue', 'Connect-EndpointGraph', + 'Get-CyotApplication', 'Ensure-CyotEndpointServicePrincipal' + ) -Variables @{ + TenantId = $customerTenant + ApplicationId = $(if ($Lookup -eq 'ClientId') { $application.AppId } else { $null }) + DisplayName = $(if ($Lookup -eq 'DisplayName') { $application.DisplayName } else { '' }) + NonInteractive = $true + } -State $state -Mocks @{ + 'Get-MgContext' = { + [CmdletBinding()] + param() + if ($script:TestState.Calls.Contains('context')) { Stop-UnmockedCall 'Repeated Stage1 context discovery' } + $script:TestState.Calls.Add('context') + $script:TestState.Context + } + 'Get-MgApplication' = { + [CmdletBinding()] + param([string] $ApplicationId, [string] $Filter, [string[]] $Property, [switch] $All) + $application = $script:TestState.Application + if ($script:GraphTenantId -ne $script:TestState.Context.TenantId) { + Stop-UnmockedCall 'Stage1 application lookup in the wrong tenant' + } + $operation = if ($ApplicationId -eq $application.Id -and -not $Filter) { 'application:object-id' } + elseif (-not $ApplicationId -and $All -and $Filter -ceq "appId eq '$($application.AppId)'") { 'application:client-id' } + elseif (-not $ApplicationId -and $All -and + $Filter -ceq "displayName eq '$($application.DisplayName.Replace("'", "''"))'") { 'application:name' } + else { Stop-UnmockedCall 'Unexpected Stage1 application lookup' } + if ($script:TestState.Calls.Contains($operation)) { Stop-UnmockedCall "Repeated Stage1 $operation" } + $script:TestState.Calls.Add($operation) + $application + } + 'Get-MgServicePrincipal' = { + [CmdletBinding()] + param([string] $Filter, [switch] $All) + if (-not $All -or $Filter -cne "appId eq '$($script:TestState.Application.AppId)'" -or + $script:TestState.Calls.Contains('service-principal:client-id')) { + Stop-UnmockedCall 'Unexpected Stage1 service-principal lookup' + } + $script:TestState.Calls.Add('service-principal:client-id') + $script:TestState.Principal + } + } + return @{ Module = $module; State = $state } +} + +# Redirect the process-only scratch location before calling functions that use GetTempPath. +# Refuse to run them if .NET resolves anywhere else, and restore the environment in finally. +$savedEnvironment = @{} +$script:fixtureDirectory = $null +Push-Location -LiteralPath $PSScriptRoot +try { + $fixtureName = ".cyot-offline-$([Guid]::NewGuid().ToString('N'))" + $script:fixtureDirectory = (New-Item -ItemType Directory -Path $fixtureName).FullName + foreach ($name in @('TMP', 'TEMP', 'TMPDIR')) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $script:fixtureDirectory, 'Process') + } + $resolvedScratch = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + Assert-Equal $resolvedScratch $script:fixtureDirectory 'all generated test files must stay under tests\setup' + + foreach ($scriptName in @($step1, $step2, $step3)) { + $label = $scriptName.Split('-')[0] + Invoke-OfflineTest "$label has no token preflight or Graph replay wrapper" { + $preflights = @($ScriptAsts[$scriptName].FindAll({ + param($node) + ($node -is [Management.Automation.Language.StringConstantExpressionAst] -and + $node.Value -eq 'get-access-token') -or + ($node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Invoke-EndpointGraph') -or + ($node -is [Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Invoke-EndpointGraph') + }, $true)) + Assert-Equal $preflights.Count 0 'authentication must not prefetch tokens or replay SDK operations' + } + + foreach ($value in @($null, '', ' ', 'not-a-guid', [Guid]::Empty.ToString())) { + Invoke-OfflineTest "$label rejects required invalid GUID <$value> without prompting" { + $module = New-OfflineModule $scriptName @('Read-SetupValue') + Assert-Throws { + & $module { + param($Value) + Read-SetupValue -Name TenantId -DefaultValue $Value -Required -ValueType Guid + } $value + } -Pattern 'TenantId.*(required|nonempty GUID)' + } + } + Invoke-OfflineTest "$label normalizes a valid GUID and preserves an omitted optional value" { + $module = New-OfflineModule $scriptName @('Read-SetupValue') + $result = & $module { + Read-SetupValue -Name TenantId -DefaultValue 'ABCDEFAB-1234-4123-8123-ABCDEFABCDEF' -Required -ValueType Guid + } + Assert-Equal $result 'abcdefab-1234-4123-8123-abcdefabcdef' 'normalize the supplied GUID' + Assert-True ($null -eq (& $module { Read-SetupValue -Name ApplicationId -ValueType Guid })) ( + 'omitting an optional ID must not prompt or synthesize an ID') + } + Invoke-OfflineTest "$label rejects an invalid supplied interactive GUID instead of prompting" { + $module = New-OfflineModule $scriptName @('Read-SetupValue') -Variables @{ NonInteractive = $false } + Assert-Throws { + & $module { Read-SetupValue -Name TenantId -DefaultValue 'invalid' -Required -ValueType Guid } + } -Pattern 'TenantId.*nonempty GUID' + } + } + + foreach ($lookup in @('ClientId', 'DisplayName')) { + Invoke-OfflineTest "Step1 reuse by $lookup returns exactly one client-ID string, never SDK objects" { + $scenario = New-Stage1ReuseScenario -Lookup $lookup + $output = @(& $scenario.Module { & $script:Stage1ReuseBody }) + Assert-Equal $output.Count 1 'the Stage1 success stream contains only the client ID' + Assert-True ($output[0] -is [string]) 'return a client-ID string, not an application or service-principal object' + Assert-Equal $output[0] $scenario.State.Application.AppId 'return the application client ID, not either object ID' + $expectedCalls = @('context') + if ($lookup -eq 'DisplayName') { $expectedCalls += 'application:name' } + $expectedCalls += @('application:client-id', 'application:object-id', 'service-principal:client-id') + Assert-Sequence $scenario.State.Calls.ToArray() $expectedCalls 'reuse the existing application and principal without writes or repeated SDK calls' + Assert-Equal (& $scenario.Module { $script:GraphTenantId }) $customerTenant 'Stage1 reuse stays in the supplied customer tenant' + } + } + Invoke-OfflineTest 'Step2 initialization remains parameterless' { + $functionAst = Get-FunctionAst $step2 'Initialize-AzureCliAuthentication' + Assert-True ($null -eq $functionAst.Body.ParamBlock -or $functionAst.Body.ParamBlock.Parameters.Count -eq 0) ( + 'initialization reads TenantId, SubscriptionId and NonInteractive from the standalone script') + } + Invoke-OfflineTest 'Step2 explicit interactive selection never reads the cached default first' { + $scenario = New-CliScenario + & $scenario.Module { Initialize-AzureCliAuthentication } + Assert-CliSelection $scenario + Assert-Equal $scenario.State.Prompts.Count 0 'supplied IDs need no input prompt' + } + Invoke-OfflineTest 'Step2 noninteractive initialization validates the existing login without signing in' { + $scenario = New-CliScenario -NonInteractive $true + & $scenario.Module { Initialize-AzureCliAuthentication } + Assert-CliSelection $scenario -Interactive $false + Assert-Equal $scenario.State.Prompts.Count 0 'noninteractive initialization must not prompt' + } + foreach ($invalidTenant in @($null, '', ' ', 'invalid', [Guid]::Empty.ToString())) { + Invoke-OfflineTest "Step2 invalid noninteractive tenant <$invalidTenant> stops before CLI calls" { + $scenario = New-CliScenario -Tenant $invalidTenant -NonInteractive $true + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'TenantId.*(required|nonempty GUID)' + Assert-Equal $scenario.State.Calls.Count 0 'invalid tenant must be rejected before CLI' + Assert-Equal $scenario.State.Prompts.Count 0 'noninteractive tenant validation must not prompt' + Assert-CliNotPublished $scenario + } + } + foreach ($invalidSubscription in @($null, '', ' ', 'invalid', [Guid]::Empty.ToString())) { + Invoke-OfflineTest "Step2 missing/invalid noninteractive subscription <$invalidSubscription> stops before CLI calls" { + $scenario = New-CliScenario -Subscription $invalidSubscription -NonInteractive $true + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'SubscriptionId.*(required|nonempty GUID)' + Assert-Equal $scenario.State.Calls.Count 0 'invalid subscription must be rejected before CLI' + Assert-Equal $scenario.State.Prompts.Count 0 'noninteractive subscription validation must not prompt' + Assert-CliNotPublished $scenario + } + } + Invoke-OfflineTest 'Step2 rejects a malformed supplied interactive subscription before login' { + $scenario = New-CliScenario -Subscription 'not-a-guid' + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'SubscriptionId.*nonempty GUID' + Assert-Equal $scenario.State.Calls.Count 0 'GUID validation precedes interactive login' + Assert-CliNotPublished $scenario + } + foreach ($onlyOne in @($false, $true)) { + Invoke-OfflineTest "Step2 omitted subscription requires an explicit tenant-filtered choice (one=$onlyOne)" { + $subscriptions = @( + (New-Subscription $foreignSubscription $otherTenant) + (New-Subscription $disabledSubscription $customerTenant 'Disabled') + (New-Subscription $selectedSubscription) + ) + if (-not $onlyOne) { $subscriptions += New-Subscription $firstSubscription } + $scenario = New-CliScenario -Subscription $null -Subscriptions $subscriptions -Answers @($selectedSubscription) + & $scenario.Module { Initialize-AzureCliAuthentication } + Assert-CliSelection $scenario + Assert-Equal $scenario.State.Prompts.Count 1 'never silently select the first/default/only subscription' + $choice = @($scenario.State.Reads | Where-Object ValueType -eq 'Choice') + Assert-Equal $choice.Count 1 'selection must go through Read-SetupValue -ValueType Choice' + Assert-Equal $choice[0].Name 'SubscriptionId' 'prompt for the subscription ID' + Assert-True $choice[0].Required 'the operator must make a choice' + Assert-True ([string]::IsNullOrWhiteSpace($choice[0].DefaultValue)) 'do not preselect a subscription' + $expectedIds = @($selectedSubscription) + if (-not $onlyOne) { $expectedIds += $firstSubscription } + Assert-Sequence $choice[0].Choices $expectedIds 'offer only enabled customer-tenant subscription IDs' + Assert-Sequence @($scenario.State.Displayed | ForEach-Object id) $expectedIds 'display only the filtered subscriptions' + Assert-Sequence @($scenario.State.Displayed | ForEach-Object name) @( + $subscriptions | Where-Object { $_.tenantId -eq $customerTenant -and $_.state -eq 'Enabled' } | ForEach-Object name + ) 'display subscription names alongside their IDs' + } + } + Invoke-OfflineTest 'Step2 rejects an out-of-tenant answer before accepting a valid choice' { + $scenario = New-CliScenario -Subscription $null -Answers @($foreignSubscription, $selectedSubscription) + & $scenario.Module { Initialize-AzureCliAuthentication } + Assert-CliSelection $scenario + Assert-Equal $scenario.State.Prompts.Count 2 'an invalid choice must be rejected and re-prompted' + } + foreach ($case in @('Empty', 'ForeignOnly', 'DisabledOnly')) { + Invoke-OfflineTest "Step2 refuses no enabled customer subscriptions ($case)" { + $subscriptions = switch ($case) { + 'Empty' { @() } + 'ForeignOnly' { New-Subscription $foreignSubscription $otherTenant } + 'DisabledOnly' { New-Subscription $selectedSubscription $customerTenant 'Disabled' } + } + $scenario = New-CliScenario -Subscriptions @($subscriptions) + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'No accessible enabled' + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) @('login', 'account list') 'stop before account show/set' + Assert-CliNotPublished $scenario + } + } + foreach ($case in @('Unknown', 'Foreign', 'Disabled', 'Duplicate')) { + Invoke-OfflineTest "Step2 refuses an inaccessible or ambiguous supplied ID ($case)" { + $id = switch ($case) { + 'Unknown' { $unknownSubscription } + 'Foreign' { $foreignSubscription } + 'Disabled' { $disabledSubscription } + 'Duplicate' { $selectedSubscription } + } + $scenario = New-CliScenario -Subscription $id + if ($case -eq 'Duplicate') { $scenario.State.Subscriptions += New-Subscription $selectedSubscription } + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'not an accessible enabled subscription' + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) @('login', 'account list') 'do not substitute another ID' + Assert-CliNotPublished $scenario + } + } + foreach ($case in @('Id', 'Tenant', 'State', 'ServicePrincipal', 'MissingUser')) { + Invoke-OfflineTest "Step2 verifies the selected account before account set ($case)" { + $account = New-Subscription $selectedSubscription + switch ($case) { + 'Id' { $account.id = $firstSubscription } + 'Tenant' { $account.tenantId = $otherTenant } + 'State' { $account.state = 'Disabled' } + 'ServicePrincipal' { $account.user.type = 'servicePrincipal' } + 'MissingUser' { $account.PSObject.Properties.Remove('user') } + } + $scenario = New-CliScenario -Account $account + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) @( + 'login', 'account list', 'account show' + ) 'reject the account before setting context' + Assert-CliNotPublished $scenario + } + } + $initializationOperations = @('login', 'account list', 'account show', 'account set') + foreach ($operation in $initializationOperations) { + Invoke-OfflineTest "Step2 propagates $operation authentication failure without retries" { + $scenario = New-CliScenario -FailAt $operation + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'Injected CLI authentication failure' + $lastIndex = [Array]::IndexOf($initializationOperations, $operation) + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) $initializationOperations[0..$lastIndex] 'stop at the first CLI failure' + Assert-CliNotPublished $scenario + } + } + Invoke-OfflineTest 'Step2 propagates subscription-not-found instead of switching or retrying login' { + $scenario = New-CliScenario -FailAt 'account show' + $scenario.State.FailureMessage = "Subscription '$selectedSubscription' not found." + Assert-Throws { & $scenario.Module { Initialize-AzureCliAuthentication } } -Pattern 'Subscription .* not found' + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) @( + 'login', 'account list', 'account show' + ) 'do not retry or set a different subscription' + Assert-CliNotPublished $scenario + } + foreach ($case in @('Resource', 'ExplicitSubscription', 'Directory')) { + Invoke-OfflineTest "Step2 Invoke-AzResult preserves explicit subscription and directory rules ($case)" { + $scenario = New-CliScenario + & $scenario.Module { $script:AzureCliContext = $script:TestState.Account } + $arguments = switch ($case) { + 'Resource' { @('resource', 'list', '--output', 'json') } + 'ExplicitSubscription' { @('resource', 'list', '--subscription', $firstSubscription, '--only-show-errors') } + 'Directory' { @('ad', 'signed-in-user', 'show', '--output', 'json') } + } + & $scenario.Module { param($Arguments) Invoke-AzResult -Arguments $Arguments } $arguments | Out-Null + Assert-Equal $scenario.State.Calls.Count 1 'forward each resource/directory operation once' + $actual = $scenario.State.Calls[0].Arguments + Assert-Equal @($actual | Where-Object { $_ -eq '--only-show-errors' }).Count 1 'add error-only output without duplicates' + $subscriptionFlags = @($actual | Where-Object { $_ -eq '--subscription' }).Count + if ($case -eq 'Directory') { + Assert-Equal $subscriptionFlags 0 'directory ad commands use initialized tenant context, not a subscription flag' + } + else { + Assert-Equal $subscriptionFlags 1 'resource operations carry exactly one subscription flag' + $expected = if ($case -eq 'ExplicitSubscription') { $firstSubscription } else { $selectedSubscription } + Assert-Equal (Get-CliOption $actual @('--subscription')) $expected 'preserve or supply the explicit subscription' + } + } + } + Invoke-OfflineTest 'Step2 Invoke-Az surfaces resource failures without hidden authentication' { + $scenario = New-CliScenario -FailAt 'resource list' + & $scenario.Module { $script:AzureCliContext = $script:TestState.Account } + Assert-Throws { & $scenario.Module { Invoke-Az resource list --output json } } -Pattern 'Injected CLI authentication failure' + Assert-Sequence @($scenario.State.Calls | ForEach-Object Operation) @('resource list') 'do not retry resource failures' + } + + $invalidContexts = @('Missing', 'WrongTenant', 'WrongEnvironment', 'AppOnly', 'MissingScope', 'ManualToken') + foreach ($scriptName in @($step1, $step2, $step3)) { + $label = $scriptName.Split('-')[0] + $scope = if ($scriptName -eq $step3) { 'Policy.ReadWrite.AuthenticationMethod' } else { 'Application.ReadWrite.All' } + Invoke-OfflineTest "$label Graph scope parameter remains string[]" { + $functionAst = Get-FunctionAst $scriptName 'Connect-EndpointGraph' + $scopeParameter = @($functionAst.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq 'Scopes' }) + Assert-Equal $scopeParameter.Count 1 'keep an explicit Scopes parameter' + Assert-Equal $scopeParameter[0].StaticType ([string[]]) 'Scopes must accept a string array' + } + foreach ($nonInteractive in @($false, $true)) { + Invoke-OfflineTest "$label reuses an eligible delegated Graph context (noninteractive=$nonInteractive)" { + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope)) $null -NonInteractive $nonInteractive + & $scenario.Module { Connect-EndpointGraph } + Assert-Equal $scenario.State.Connections.Count 0 'reuse an eligible refreshable delegated context' + Assert-Equal $scenario.State.Reads 1 'reuse requires one context check' + Assert-Equal (& $scenario.Module { $script:GraphTenantId }) $customerTenant 'keep the explicitly selected tenant' + } + } + foreach ($variant in $invalidContexts) { + Invoke-OfflineTest "$label rejects noninteractive Graph context $variant without sign-in" { + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope) $variant) $null -NonInteractive $true + Assert-Throws { & $scenario.Module { Connect-EndpointGraph } } -Pattern 'Connect-MgGraph.*TenantId' + Assert-Equal $scenario.State.Connections.Count 0 'noninteractive mode must never connect' + Assert-Equal $scenario.State.Reads 1 'inspect the existing context without retries' + } + Invoke-OfflineTest "$label replaces Graph context $variant with one pinned sign-in" { + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope) $variant) (New-GraphContext @($scope)) + & $scenario.Module { Connect-EndpointGraph } + Assert-GraphConnection $scenario @($scope) + } + Invoke-OfflineTest "$label revalidates returned Graph context $variant" { + $scenario = New-GraphScenario $scriptName $null (New-GraphContext @($scope) $variant) + Assert-Throws { & $scenario.Module { Connect-EndpointGraph } } -Pattern 'refreshable delegated session' + Assert-GraphConnection $scenario @($scope) + } + } + foreach ($tenant in @($null, 'invalid', [Guid]::Empty.ToString())) { + Invoke-OfflineTest "$label rejects invalid customer tenant <$tenant> before Graph context discovery" { + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope)) $null -Tenant $tenant -NonInteractive $true + Assert-Throws { & $scenario.Module { Connect-EndpointGraph } } -Pattern 'TenantId.*(required|nonempty GUID)' + Assert-Equal $scenario.State.Reads 0 'never infer a customer tenant from a cached Graph session' + Assert-Equal $scenario.State.Connections.Count 0 'invalid tenants cannot trigger sign-in' + } + } + foreach ($invalidScopes in @(@(), @(''), @($scope, ' '))) { + Invoke-OfflineTest "$label rejects empty Graph scopes <$($invalidScopes -join ',')>" { + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope)) $null + Assert-Throws { + & $scenario.Module { param($Scopes) Connect-EndpointGraph -Scopes $Scopes } $invalidScopes + } -Pattern 'nonempty scope' + Assert-Equal $scenario.State.Reads 0 'validate scopes before inspecting context' + Assert-Equal $scenario.State.Connections.Count 0 'invalid scopes cannot trigger sign-in' + } + } + Invoke-OfflineTest "$label forwards and checks every explicitly requested Graph scope" { + $scopes = @($scope, 'Directory.Read.All') + $scenario = New-GraphScenario $scriptName (New-GraphContext @($scope)) (New-GraphContext $scopes) + & $scenario.Module { param($Scopes) Connect-EndpointGraph -Scopes $Scopes } $scopes + Assert-GraphConnection $scenario $scopes + } + Invoke-OfflineTest "$label rejects an incomplete returned Graph scope set" { + $scopes = @($scope, 'Directory.Read.All') + $scenario = New-GraphScenario $scriptName $null (New-GraphContext @($scope)) + Assert-Throws { + & $scenario.Module { param($Scopes) Connect-EndpointGraph -Scopes $Scopes } $scopes + } -Pattern 'refreshable delegated session' + Assert-GraphConnection $scenario $scopes + } + Invoke-OfflineTest "$label propagates Graph sign-in failure without retry/fallback" { + $scenario = New-GraphScenario $scriptName $null $null -ConnectError 'Injected Graph sign-in failure: AADSTS50076' + Assert-Throws { & $scenario.Module { Connect-EndpointGraph } } -Pattern 'Injected Graph sign-in failure' + Assert-Equal $scenario.State.Connections.Count 1 'only one Graph sign-in attempt' + Assert-Equal $scenario.State.Connections[0].TenantId $customerTenant 'the only sign-in targets the selected tenant' + Assert-Equal $scenario.State.Reads 1 'do not replay failed sign-in or request extra tokens' + } + Invoke-OfflineTest "$label propagates Graph context failure without hidden sign-in" { + $scenario = New-GraphScenario $scriptName $null $null -ContextError 'Injected Graph context failure' + Assert-Throws { & $scenario.Module { Connect-EndpointGraph } } -Pattern 'Injected Graph context failure' + Assert-Equal $scenario.State.Connections.Count 0 'do not turn a context error into another login' + Assert-Equal $scenario.State.Reads 1 'do not retry failed context discovery' + } + } + + . (Join-Path $PSScriptRoot 'Test-CyotArm.ps1') + + Invoke-OfflineTest 'Step3 explicit false remains a required Boolean value, not a missing input' { + $module = New-OfflineModule $step3 @('Read-SetupValue') + $value = & $module { Read-SetupValue -Name Migrated -DefaultValue $false -Required -ValueType Boolean } + Assert-True ($value -is [bool]) 'return a Boolean rather than a string' + Assert-Equal $value $false 'preserve the explicit new-CYOT-only migration choice' + Assert-Throws { + & $module { Read-SetupValue -Name Migrated -DefaultValue $null -Required -ValueType Boolean } + } -Pattern 'Migrated.*required' + } + Invoke-OfflineTest 'Step3 unsupported schema stops before inputs, sign-in, approvals, backup or policy I/O' { + $module = New-OfflineModule $step3 @('Invoke-CyotPolicyUpdate') -Variables @{ NonInteractive = $false } + $snapshot = Join-Path $script:fixtureDirectory 'must-not-create-policy-backup.json' + $filesBefore = @(Get-ChildItem -LiteralPath $script:fixtureDirectory -Recurse -Force | ForEach-Object FullName | Sort-Object) + Assert-Throws { + & $module { + param($Snapshot) + Invoke-CyotPolicyUpdate -SchemaStatus ([pscustomobject]@{ + Supported = $false; Reason = 'Synthetic unsupported CYOT schema' + PolicyUri = 'https://graph.example.invalid/beta/policies/authenticationMethodsPolicy' + }) -SnapshotPath $Snapshot + } $snapshot + } -Pattern 'Synthetic unsupported CYOT schema.*No policy change was attempted' + Assert-True (-not (Test-Path -LiteralPath $snapshot)) 'unsupported schema must not create a backup' + Assert-Sequence @(Get-ChildItem -LiteralPath $script:fixtureDirectory -Recurse -Force | ForEach-Object FullName | Sort-Object) $filesBefore 'unsupported schema has no file side effects' + } + Invoke-OfflineTest 'Step3 refuses to normalize away unknown existing policy fields' { + $module = New-OfflineModule $step3 @('Get-CyotPolicyState') + Assert-Throws { + & $module { + Get-CyotPolicyState -Policy ([pscustomobject]@{ cyot = [pscustomobject]@{ + endpoint = 'https://delivery.example.invalid/api/SendOtp' + appId = '33333333-3333-4333-8333-333333333333'; migrated = $false + unexpected = 'must not be overwritten' + } }) + } + } -Pattern 'unsupported fields' + } +} +finally { + foreach ($entry in $savedEnvironment.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process') + } + if ($script:fixtureDirectory -and (Test-Path -LiteralPath $script:fixtureDirectory)) { + Remove-Item -LiteralPath $script:fixtureDirectory -Recurse -Force + } + Pop-Location +} + +Write-Host "`nOffline behavioral checks: $script:passed passed; $($script:failures.Count) failed." +if ($script:failures.Count) { + throw "CYOT offline regressions failed:`n$($script:failures -join "`n")" +} diff --git a/tests/setup/Test-CyotScripts.ps1 b/tests/setup/Test-CyotScripts.ps1 new file mode 100644 index 0000000..8e5d8e2 --- /dev/null +++ b/tests/setup/Test-CyotScripts.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$setupDirectory = Join-Path $PSScriptRoot '..\..\setup\cyot' +$scriptNames = @( + 'Step1-Register-CyotApplication.ps1' + 'Step2-Setup-ExternalPhoneProvider.ps1' + 'Step3-Set-CyotPolicy.ps1' +) +$scriptAsts = @{} + +foreach ($name in $scriptNames) { + $path = (Resolve-Path -LiteralPath (Join-Path $setupDirectory $name)).Path + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $path, [ref] $tokens, [ref] $parseErrors) + if ($parseErrors.Count) { + $details = $parseErrors | ForEach-Object { + "Line $($_.Extent.StartLineNumber): $($_.Message)" + } + throw "PowerShell syntax errors in ${name}:`n$($details -join "`n")" + } + + $commands = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true) + foreach ($command in $commands) { + if ($command.InvocationOperator -eq [System.Management.Automation.Language.TokenKind]::Dot -or + $command.GetCommandName() -match '\.ps1$') { + throw "$name must remain standalone; found a script import/invocation at line $($command.Extent.StartLineNumber)." + } + } + + Write-Host "${name}: syntax and standalone-import checks passed." + $scriptAsts[$name] = $ast +} + +& (Join-Path $PSScriptRoot 'Test-CyotBehavior.ps1') -ScriptAsts $scriptAsts