From 76c678e15de48411cffebd1410d894db9bd2908f Mon Sep 17 00:00:00 2001 From: Jan Egil Ring Date: Wed, 30 Sep 2026 18:26:15 +0000 Subject: [PATCH] feat: Enhance deployment security by using temporary parameter files for admin passwords Signed-off-by: Jan Egil Ring --- .../04-adaptive-apps/labautomation/README.md | 10 +++ .../labautomation/deploy-lab.ps1 | 28 ++++++-- .../labautomation/tests/console.Tests.ps1 | 69 ++++++++++++++++++- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md index 00baba9b1..06b77fd65 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/README.md @@ -121,6 +121,16 @@ rotate it. It is never printed or returned to participants: connect uses Run Command for kubeconfig retrieval and Bastion for the API, not SSH credentials. Organizer troubleshooting can use Run Command. +The deployment receives a temporary ARM parameter file rather than a +`SecureString` embedded in PowerShell command arguments, avoiding secure-string +serialization at the Console job boundary. The file contains the password in +plaintext inside the job's private `0700` scratch directory, with file permissions +`0600`; it is deleted as soon as the deployment command returns or throws. +The template still declares `adminPassword` as `@secure()`, so ARM does not +record its value in deployment history. Do not print or publish the parameter +file. Stage messages identify context validation, parameter preparation and +deployment submission without including credentials. + The participant hook uses an **incremental deployment**, retaining resources and failed deployment records for diagnosis. It grants each supplied participant **Reader on this AKS node RG only**, so the PostgreSQL recipe can discover AKS diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 index 3a76a9960..391f3a794 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/deploy-lab.ps1 @@ -34,6 +34,7 @@ try { Receive-AdaptiveBootstrap -Manifest $sourceManifest -Destination $workingRoot Update-MhhToken | Out-Null + Write-Host 'Validating participant Azure contexts and shared region metadata...' Assert-AdaptiveContext -SubscriptionId $SubscriptionId $group = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop $region = $group.Location.ToLowerInvariant() @@ -47,13 +48,26 @@ try { CostControl = 'Ignore' } -ErrorAction Stop | Out-Null - $password = New-MhhStablePassword -Purpose 'adaptive-apps-k3s-admin' -Length 24 - $deployment = New-AzResourceGroupDeployment -Name 'adaptive-apps-console' ` - -ResourceGroupName $ResourceGroupName -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') ` - -TemplateParameterObject @{ - location = $region - adminPassword = (ConvertTo-SecureString $password -AsPlainText -Force) - } -Mode Incremental -ErrorAction Stop + Write-Host 'Preparing private infrastructure deployment parameters...' + $parameterFile = Join-Path $scratch 'main.parameters.json' + @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = @{ + location = @{ value = $region } + adminPassword = @{ value = (New-MhhStablePassword -Purpose 'adaptive-apps-k3s-admin' -Length 24) } + } + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $parameterFile -Encoding utf8 + & chmod 600 $parameterFile + if ($LASTEXITCODE -ne 0) { throw 'Unable to secure infrastructure parameter file.' } + Write-Host 'Submitting incremental Adaptive Apps infrastructure deployment...' + try { + $deployment = New-AzResourceGroupDeployment -Name 'adaptive-apps-console' ` + -ResourceGroupName $ResourceGroupName -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') ` + -TemplateParameterFile $parameterFile -Mode Incremental -ErrorAction Stop + } finally { + Remove-Item -LiteralPath $parameterFile -Force + } if ($deployment.ProvisioningState -ne 'Succeeded') { throw "Infrastructure deployment failed: $($deployment.ProvisioningState). Resources are retained for diagnosis; no destructive fallback is attempted." } diff --git a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 index fdbf67ab5..d44fe9862 100644 --- a/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 +++ b/03-Azure/01-01-App Innovation/04-adaptive-apps/labautomation/tests/console.Tests.ps1 @@ -8,7 +8,7 @@ BeforeAll { function Update-AzTag { [CmdletBinding()] param($ResourceId, $Operation, $Tag) } function New-MhhStablePassword { param($Purpose, $Length) } function New-AzResourceGroupDeployment { - [CmdletBinding()] param($Name, $ResourceGroupName, $TemplateFile, $TemplateParameterObject, $Mode) + [CmdletBinding()] param($Name, $ResourceGroupName, $TemplateFile, $TemplateParameterObject, $TemplateParameterFile, $Mode) } function New-AzSubscriptionDeployment { [CmdletBinding()] param($Name, $Location, $TemplateFile) } function New-AzResourceGroup { [CmdletBinding()] param($Name, $Location, $Tag, [switch]$Force) } @@ -164,6 +164,9 @@ Describe 'Participant hook orchestration' { $script:location = 'westeurope' $script:guestMarker = 'ADAPTIVE_K3S_READY' $script:deploymentState = 'Succeeded' + $script:deploymentThrows = $false + $script:useDeploymentJob = $false + $script:observedParameterFile = '' $script:tagWrites = [Collections.Generic.List[object]]::new() $script:oldAzureConfig = $env:AZURE_CONFIG_DIR $script:originalHome = $env:HOME @@ -195,8 +198,46 @@ Describe 'Participant hook orchestration' { Mock New-MhhStablePassword { 'TestOnlyStablePassword123' } Mock New-AzResourceGroupDeployment { $Mode | Should -Be 'Incremental' - $TemplateParameterObject.location | Should -Be $script:location - $TemplateParameterObject.adminPassword | Should -BeOfType [securestring] + $TemplateParameterObject | Should -BeNullOrEmpty + $TemplateParameterFile | Should -Not -BeNullOrEmpty + $script:observedParameterFile = $TemplateParameterFile + $TemplateParameterFile | Should -Be (Join-Path $script:observedHome 'scratch/main.parameters.json') + [IO.File]::GetUnixFileMode($TemplateParameterFile) | + Should -Be ([IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + [IO.File]::GetUnixFileMode((Split-Path $TemplateParameterFile -Parent)) | + Should -Be ([IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor [IO.UnixFileMode]::UserExecute) + $parameters = Get-Content -LiteralPath $TemplateParameterFile -Raw | ConvertFrom-Json + $parameters.'$schema' | Should -Be 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + $parameters.contentVersion | Should -Be '1.0.0.0' + $parameters.parameters.location.value | Should -Be $script:location + $parameters.parameters.adminPassword.value | Should -BeExactly 'TestOnlyStablePassword123' + $serialized = [Management.Automation.PSSerializer]::Serialize($PesterBoundParameters, 10) + $serialized | Should -Not -Match 'TestOnlyStablePassword|]' + if ($script:useDeploymentJob) { + $job = Start-Job -ArgumentList $PesterBoundParameters, $script:deploymentThrows -ScriptBlock { + param($DeploymentArguments, $Fail) + function Invoke-TestDeployment { + [CmdletBinding()] + param($Name, $ResourceGroupName, $TemplateFile, $TemplateParameterFile, $Mode) + $parameters = Get-Content -LiteralPath $TemplateParameterFile -Raw | ConvertFrom-Json + if ($parameters.parameters.adminPassword.value -cne 'TestOnlyStablePassword123') { + throw 'Deployment job could not read the synthetic password.' + } + if ($Fail) { throw 'Simulated ARM deployment exception' } + 'Parameter file read successfully in child job' + } + Invoke-TestDeployment @DeploymentArguments + } + try { + $completed = $job | Wait-Job -Timeout 30 + if (-not $completed) { throw 'Deployment serialization test job timed out.' } + Receive-Job $job -ErrorAction Stop | Should -Be 'Parameter file read successfully in child job' + } finally { + Remove-Job $job -Force + } + } elseif ($script:deploymentThrows) { + throw 'Simulated ARM deployment exception' + } @{ ProvisioningState = $script:deploymentState Outputs = @{ @@ -227,6 +268,7 @@ Describe 'Participant hook orchestration' { } } Mock bash { + Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse $script:calls[-1] | Should -Be 'refresh' $script:calls.Add("bash $args") $script:observedHome = $env:HOME @@ -258,6 +300,7 @@ Describe 'Participant hook orchestration' { $env:HOME | Should -Be $script:originalHome $env:PATH | Should -Be $script:originalPath if ($script:observedHome) { Test-Path -LiteralPath $script:observedHome | Should -BeFalse } + if ($script:observedParameterFile) { Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse } [IO.File]::Exists((Join-Path $script:sourceRoot 'artifacts/console-mock-marker')) | Should -BeFalse $env:AZURE_CONFIG_DIR = $script:oldAzureConfig } @@ -278,6 +321,26 @@ Describe 'Participant hook orchestration' { { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | Should -Throw '*exit code 13*' @($script:tagWrites | Where-Object { $_.adaptiveAppsReady -eq 'true' }) | Should -HaveCount 0 } + It 'passes deployment arguments through a real child job without serializing a SecureString' { + $script:useDeploymentJob = $true + $result = . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') + $result.HackboxCredential.name | Should -Contain 'Adaptive Apps Connect' + ($result | ConvertTo-Json -Depth 5) | Should -Not -Match 'TestOnlyStablePassword' + Should -Invoke New-AzResourceGroupDeployment -Times 1 -Exactly + } + It 'preserves deployment exceptions and removes private parameters on failure' -ForEach @( + @{ ChildJob = $false } + @{ ChildJob = $true } + ) { + $script:useDeploymentJob = $ChildJob + $script:deploymentThrows = $true + { . (Join-Path $automation 'deploy-lab.ps1') -DeploymentType resourcegroup -SubscriptionId $subscription -ResourceGroupName lab-one -AllowedEntraUserIds @('user') } | + Should -Throw '*Simulated ARM deployment exception*' + $script:observedParameterFile | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath $script:observedParameterFile | Should -BeFalse + @($script:tagWrites | Where-Object { $_.adaptiveAppsReady -eq 'true' }) | Should -HaveCount 0 + Should -Invoke bash -Times 0 + } It 'deploys from only a read-only Console lab folder without sibling workshop sources' { $readOnlyRoot = Join-Path $TestDrive 'read-only-source' New-Item -ItemType Directory -Path $readOnlyRoot | Out-Null