#requires -Version 7.5 [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('Preflight','Plan','Deploy','Bootstrap','Configure','Validate','Fault','Repair','Cleanup','Export')][string]$Stage, [Parameter(Mandatory)][string]$StatePath, [ValidateSet('Foundation','Identity')][string]$Phase = 'Foundation', [string]$SubscriptionId, [ValidateSet('Kerberos','ShareGate','Ntfs')][string]$Scenario, [switch]$Execute,[switch]$AcknowledgeCost,[switch]$ReviewedPlan,[switch]$Resume,[switch]$CollectLogs, [switch]$CaptureAccessVerified, [ValidateSet('westeurope')][string]$Location = 'westeurope', [string]$VnetCidr = '10.172.0.0/22', [string]$ImageUrn = 'MicrosoftWindowsServer:WindowsServer:2025-datacenter-azure-edition-smalldisk:26100.33674.261025', [int]$PropagationWaitSeconds = 2100,[int]$LogWaitSeconds = 1800 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'scripts/Workshop.Common.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'scripts/AzureFiles.Common.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'scripts/AzureFiles.Acceptance.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'scripts/AzureFiles.Rejections.psm1') -Force Import-Module (Join-Path $PSScriptRoot 'scripts/AzureFiles.RunCommands.psm1') -Force $privateDirectory = Initialize-WorkshopPrivateDirectory -StatePath $StatePath -PackageRoot $PSScriptRoot $StatePath = [IO.Path]::GetFullPath($StatePath) $runLock = Enter-WorkshopLock -PrivateDirectory $privateDirectory $script:state = $null $infra = Join-Path $PSScriptRoot 'infra' $tfstate = Join-Path $privateDirectory 'terraform.tfstate' $tfvars = Join-Path $privateDirectory 'terraform.tfvars.json' $secretPath = Join-Path $privateDirectory 'secrets.dpapi' $savedEnv = @{ TF_DATA_DIR=$env:TF_DATA_DIR; ARM_SUBSCRIPTION_ID=$env:ARM_SUBSCRIPTION_ID; ARM_TENANT_ID=$env:ARM_TENANT_ID } $env:TF_DATA_DIR = Join-Path $privateDirectory 'tfdata' function Save-Run { Write-WorkshopJson -Path $StatePath -Value $script:state } function Invoke-Az([string[]]$A,[int]$Timeout=300) { if ($script:state -and $A[0] -notin @('account','version') -and '--subscription' -notin $A) {$A+=@('--subscription',$script:state.subscription_id)} Invoke-WorkshopAzJson -Arguments $A -PrivateDirectory $privateDirectory -TimeoutSeconds $Timeout } function Invoke-Tf([string[]]$A,[int]$Timeout=1800,[switch]$AllowFailure) { Invoke-WorkshopNative -FilePath terraform -Arguments (@("-chdir=$infra")+$A) -PrivateDirectory $privateDirectory -TimeoutSeconds $Timeout -AllowFailure:$AllowFailure -Label 'terraform' } function Add-Check([string]$Name,[bool]$Passed,[hashtable]$Observed=@{},[string]$CheckPhase='') { try { Add-WorkshopCheck -State $script:state -Name $Name -Passed $Passed -Scenario $Scenario -Phase $CheckPhase -Observed $Observed } finally { Save-Run } } function Start-Operation([string]$Kind) { $script:state.pending_operation=@{stage=$Stage;phase=$Phase;scenario=$Scenario;kind=$Kind;started_utc=[DateTimeOffset]::UtcNow.ToString('o');local_pid=$PID;local_process_start_utc=(Get-Process -Id $PID).StartTime.ToUniversalTime().ToString('o')}; Save-Run } function Complete-Operation { $script:state.operations += $script:state.pending_operation; $script:state.pending_operation=$null; Save-Run } function Assert-InterruptedOperationSettled { $pending=$script:state.pending_operation if (-not $pending) {return} if ($pending.ContainsKey('local_pid') -and $pending.local_pid -ne $PID) { $old=Get-Process -Id $pending.local_pid -ErrorAction SilentlyContinue if ($old -and $old.StartTime.ToUniversalTime().ToString('o') -eq $pending.local_process_start_utc) {throw 'The original controller process is still running.'} } if ($pending.ContainsKey('guest_command_id')) { $rejected=$false try {$command=Invoke-Az @('rest','--method','get','--url',($pending.guest_command_id+'?api-version=2023-03-01&$expand=instanceView'))} catch { $nativeError=[string]$_.Exception.Data['NativeStdErr'];$leaf=($pending.guest_command_id -split '/')[-1] if(-not (Test-AzureFilesRunCommandNotFound -CommandId $pending.guest_command_id -NativeError $nativeError -RequestUri ($pending.guest_command_id+'?api-version=2023-03-01&$expand=instanceView'))){throw} if(-not (Test-AzureFilesRejectedCreationEvidence -State $script:state -PrivateDirectory $privateDirectory)){ if(-not $pending.ContainsKey('put_rejection_candidate') -or -not $_.Exception.Data['NativeLogPath']){throw} $pending.put_rejection_evidence=Write-AzureFilesRejectedCreationEvidence -State $script:state -PrivateDirectory $privateDirectory -ErrorLogBasename $pending.put_rejection_candidate.error_log_basename -AbsenceLogBasename ([IO.Path]::GetFileName([string]$_.Exception.Data['NativeLogPath'])) Save-Run } if(-not (Test-AzureFilesRejectedCreationEvidence -State $script:state -PrivateDirectory $privateDirectory)){throw 'Rejected creation evidence is incomplete or changed.'} $parentId=$pending.guest_command_id -replace '/runCommands/[^/]+$','' $parent=Invoke-Az @('rest','--method','get','--url',($parentId+'?api-version=2023-03-01')) $children=Invoke-Az @('rest','--method','get','--url',($parentId+'/runCommands?api-version=2023-03-01')) if($parent.id -ine $parentId -or -not $children.ContainsKey('value') -or @($children.value|Where-Object {$_.id -eq $pending.guest_command_id}).Count){throw 'Rejected command absence is not corroborated by its owned parent inventory.'} $pending.creation_rejected=$true;$rejected=$true } if (-not $rejected -and (-not $command.properties.ContainsKey('instanceView') -or $command.properties.instanceView.executionState -notin @('Succeeded','Failed','Canceled','TimedOut'))) {throw 'The original guest command is not terminal; reconcile it before resuming.'} } if ($pending.stage -in @('Deploy','Bootstrap','Cleanup')) { foreach ($resource in @(Invoke-Az @('resource','list','--resource-group',($script:state.prefix+'-rg')))) { if ($resource.type -notin @('Microsoft.Compute/virtualMachines','Microsoft.Network/privateEndpoints','Microsoft.Network/bastionHosts','Microsoft.Network/natGateways','Microsoft.Storage/storageAccounts')) {continue} $live=Invoke-Az @('resource','show','--ids',$resource.id) if (-not $live.properties.ContainsKey('provisioningState') -or $live.properties.provisioningState -notin @('Succeeded','Failed','Canceled')) {throw 'An original Azure deployment operation has not reached a verified terminal state.'} } } } function Assert-Mutation { if (-not $Execute) { throw 'This stage requires -Execute after reviewing its exact scope.' } Assert-WorkshopContext -State $script:state -PrivateDirectory $privateDirectory | Out-Null if ($Stage -ne 'Cleanup' -and $script:state.cleanup_count -gt 0) {throw 'This run is closed after cleanup. Use a new private state path for a new deployment.'} if($Stage -eq 'Fault' -and $script:state.ContainsKey('require_fresh_baseline_after_rejection') -and $script:state.require_fresh_baseline_after_rejection){throw 'Run a fresh healthy Validate before retrying a creation-rejected fault.'} if ($Stage -notin @('Cleanup','Repair') -and [DateTimeOffset]::UtcNow -ge [DateTimeOffset]::Parse($script:state.exercise_stop_utc)) { throw 'The 4.5-hour exercise deadline has arrived. Clean up now.' } if ($script:state.pending_operation) { if (-not $Resume) { throw 'Reconcile the recorded pending operation before resuming; do not start a competing operation.' } Assert-InterruptedOperationSettled $pending=$script:state.pending_operation $same=$pending.stage -eq $Stage -and $pending.phase -eq $Phase -and $pending.scenario -eq $Scenario $repair=$Stage -eq 'Repair' -and $pending.stage -in @('Fault','Validate') -and $pending.scenario -eq $Scenario -and $pending.phase -eq $Phase -and $script:state.active_fault -eq $Scenario $cleanup=$Stage -eq 'Cleanup' $baselineSetup=$Stage -eq 'Configure' -and $pending.stage -eq 'Validate' -and $pending.phase -eq $Phase -and -not $pending.scenario -and -not $Scenario -and -not $script:state.active_fault -and $script:state.fault_counts.Count -eq 0 if (-not ($same -or $repair -or $cleanup -or $baselineSetup)) { throw 'Resume must match the recorded operation, repair its exact fault, reconcile initial baseline setup before any fault, or clean up the owned run.' } $freshBaseline=$pending.ContainsKey('creation_rejected') -and $pending.creation_rejected -and $pending.stage -eq 'Fault' -and $script:state.active_fault -eq $pending.scenario -and -not $script:state.fault_counts.ContainsKey($pending.scenario) if($freshBaseline){$script:state.active_fault='';$script:state.require_fresh_baseline_after_rejection=$true;$pending.rejected_unexecuted_fault_intent_cleared=$true} $pending.reconciled_utc=[DateTimeOffset]::UtcNow.ToString('o');$pending.reconciled_by='Explicit '+$Stage+' resume after verified terminal operation; no success inferred' $script:state.operations+=$pending;$script:state.pending_operation=$null;Save-Run if($freshBaseline -and $Stage -ne 'Cleanup'){throw 'Rejected request archived without execution; run a fresh healthy Validate before retrying the fault.'} } } function Output-Value([string]$Name) { if (-not $script:state.outputs.ContainsKey($Name)) { throw "Missing recorded output: $Name" } return $script:state.outputs[$Name] } function Get-Scope { return "/subscriptions/$($script:state.subscription_id)/resourceGroups/$($script:state.prefix)-rg" } function Sync-Ownership { if (-not (Test-Path -LiteralPath $tfstate)) { return } $snapshot=(Invoke-Tf @('show','-json',$tfstate)).StdOut|ConvertFrom-Json -AsHashtable -Depth 100 if (-not $snapshot.ContainsKey('values') -or -not $snapshot.values -or -not $snapshot.values.ContainsKey('root_module')) { return } $scope=Get-Scope $owned=[Collections.Generic.List[string]]::new() foreach ($resource in @($snapshot.values.root_module.resources)) { if ($resource.mode -ne 'managed' -or -not $resource.values.ContainsKey('id')) { continue } $id=[string]$resource.values.id if ($resource.type -eq 'azurerm_monitor_diagnostic_setting') { $id=$id.Split('|')[0] } if (-not (Test-AzureFilesOwnedId -Id $id -Scope $scope -Baseline $script:state.baseline_resource_ids)) { throw 'Terraform state references a protected or out-of-scope resource.' } if (-not $owned.Contains($id)) { $owned.Add($id) } if ($resource.type -eq 'azurerm_windows_virtual_machine') { $vm=Invoke-Az @('vm','show','--ids',$id) $disk=[string]$vm.storageProfile.osDisk.managedDisk.id $expected=$scope+'/providers/Microsoft.Compute/disks/'+$resource.values.os_disk[0].name if ($disk -ne $expected -or $disk -in $script:state.baseline_resource_ids) { throw 'An OS disk differs from its exact owned VM declaration.' } if (-not $owned.Contains($disk)) { $owned.Add($disk) } foreach ($ext in @(Invoke-Az @('vm','extension','list','--ids',$id))) { if ($ext.publisher -eq 'Microsoft.CPlat.Core' -and $ext.typePropertiesType -in @('RunCommandWindows','RunCommandHandlerWindows') -and $ext.name -in @('RunCommandWindows','RunCommandHandlerWindows') -and $ext.id -eq ($id+'/extensions/'+$ext.name) -and $ext.id -notin $script:state.baseline_resource_ids) { if (-not $owned.Contains([string]$ext.id)) { $owned.Add([string]$ext.id) } } } } if ($resource.type -eq 'azurerm_private_endpoint') { $endpoint=Invoke-Az @('network','private-endpoint','show','--ids',$id) foreach ($nic in @($endpoint.networkInterfaces)) { $expected=$scope+'/providers/Microsoft.Network/networkInterfaces/'+$resource.values.custom_network_interface_name if ($nic.id -ne $expected -or $nic.id -in $script:state.baseline_resource_ids) { throw 'Private endpoint NIC ownership could not be proven.' } if (-not $owned.Contains([string]$nic.id)) { $owned.Add([string]$nic.id) } } } } foreach ($command in $script:state.guest_commands) { if (-not (Test-AzureFilesOwnedId -Id $command.id -Scope $scope -Baseline $script:state.baseline_resource_ids)) { throw 'Recorded guest command is outside its owned group.' } if (-not $owned.Contains([string]$command.id)) { $owned.Add([string]$command.id) } } $script:state.owned_ids=$owned.ToArray() if ($snapshot.values.ContainsKey('outputs')) { foreach ($key in $snapshot.values.outputs.Keys) { $script:state.outputs[$key]=$snapshot.values.outputs[$key].value } } Save-Run } function Assert-OwnedMembers { $groupName=$script:state.prefix+'-rg' $group=Invoke-Az @('group','show','--name',$groupName) if ($group.id -ne (Get-Scope) -or $group.tags.workshop_run -ne $script:state.prefix) { throw 'Resource group identity or ownership tag mismatch.' } foreach ($resource in @(Invoke-Az @('resource','list','--resource-group',$groupName))) { if ($resource.id -notin $script:state.owned_ids) { throw 'The workshop group contains an unexpected resource. Refusing broad deletion.' } } } function Get-GuestInput { $accounts=@() foreach ($role in @('control','exercise')) { $account=$script:state.outputs.storage_accounts[$role] $accounts+=@{role=$role;name=$account.name;samAccountName=$script:state.storage_sam_names[$role];shareName=$account.share_name;rootRelativePath='workshop-'+$script:state.run_id;expectedPrivateIp=$account.private_endpoint_ip} } return @{runId=$script:state.run_id;domainName='awzfiles.test';netbiosName='AWZFILES';dcIp=(Output-Value 'dc_private_ip');storageAccounts=$accounts;subscriptionId=$script:state.subscription_id;tenantId=$script:state.tenant_id;resourceGroupName=(Output-Value 'resource_group_name');bootstrapIdentityClientId=$(if($script:state.outputs.ContainsKey('dc_identity_client_id')){Output-Value 'dc_identity_client_id'}else{''})} } function Invoke-Guest([string]$Machine,[string]$Action,[hashtable]$Extra=@{},[int]$Timeout=1200) { $vmName=Output-Value ($Machine+'_vm_name'); $vmId=Output-Value ($Machine+'_vm_id') $commandName='awz-'+$Action.ToLowerInvariant()+'-'+[guid]::NewGuid().ToString('N').Substring(0,8) $commandId=$vmId+'/runCommands/'+$commandName if ($vmId -notin $script:state.owned_ids -or -not (Test-AzureFilesOwnedId -Id $commandId -Scope (Get-Scope) -Baseline $script:state.baseline_resource_ids)) {throw 'Guest command target is not an exact recorded owned VM.'} $null=Invoke-AzureFilesRunCommandMaintenance -State $script:state -Machine $Machine -PrivateDirectory $privateDirectory -Execute $guestInput=Get-GuestInput foreach ($key in $Extra.Keys) { $guestInput[$key]=$Extra[$key] } $secrets=Unprotect-AzureFilesSecrets -Path $secretPath $body=@{location=$script:state.location;tags=@{workshop_run=$script:state.prefix};properties=@{ source=@{script=[IO.File]::ReadAllText((Join-Path $PSScriptRoot 'scripts/guest/AzureFiles.Guest.ps1'))}; parameters=@(@{name='Action';value=$Action},@{name='InputJson';value=($guestInput|ConvertTo-Json -Compress -Depth 25)}); protectedParameters=@(@{name='SecretJson';value=($secrets|ConvertTo-Json -Compress -Depth 25)}); timeoutInSeconds=$Timeout;treatFailureAsDeploymentFailure=$true;asyncExecution=$false }} $bodyPath=Join-Path $privateDirectory ('guest-'+$commandName+'.json') Write-WorkshopJson -Path $bodyPath -Value $body $script:state.guest_commands+=@{id=$commandId;machine=$Machine;action=$Action;utc=[DateTimeOffset]::UtcNow.ToString('o')} $script:state.owned_ids+=@($commandId);Save-Run if ($script:state.pending_operation) {$script:state.pending_operation.guest_command_id=$commandId;Save-Run} try { Invoke-Az @('rest','--method','put','--url',($commandId+'?api-version=2023-03-01'),'--body',('@'+$bodyPath)) ($Timeout+120) | Out-Null $limit=[DateTimeOffset]::UtcNow.AddSeconds($Timeout+120) do { $execution=Invoke-Az @('vm','run-command','show','--name',$commandName,'--vm-name',$vmName,'--resource-group',(Output-Value 'resource_group_name'),'--expand','instanceView') if ($execution.ContainsKey('instanceView') -and $execution.instanceView -and $execution.instanceView.executionState -in @('Succeeded','Failed','Canceled','TimedOut')) { break } if ([DateTimeOffset]::UtcNow -ge $limit) { throw 'Managed guest command remains unfinished. Reconcile this exact command before retrying.' } Start-Sleep -Seconds 10 } while ($true) if ($execution.instanceView.executionState -ne 'Succeeded' -or [int]$execution.instanceView.exitCode -ne 0) { throw "Guest action $Action failed. Its private instance-view report must be inspected." } $result=ConvertFrom-AzureFilesGuestOutput -Output ([string]$execution.instanceView.output) if (-not $result.ok -or $result.action -ne $Action -or $result.runId -ne $script:state.run_id) {throw 'Guest result does not match this exact run and action.'} $evidenceName=[DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfff')+'-'+$Action+'.json' Write-WorkshopJson -Path (Join-Path $privateDirectory ('evidence/'+$evidenceName)) -Value $result $script:state.guest_reports+=@{action=$Action;machine=$Machine;command_id=$commandId;file=$evidenceName;utc=[DateTimeOffset]::UtcNow.ToString('o');sha256=(Get-WorkshopFileHash (Join-Path $privateDirectory ('evidence/'+$evidenceName)))} Save-Run return $result.result } catch { $record=@{exit_code=$_.Exception.Data['NativeExitCode'];stderr=$_.Exception.Data['NativeStdErr']} if($script:state.pending_operation -and $_.Exception.Data['NativeLogPath'] -and (Test-AzureFilesCommandLimitError $record)){ $script:state.pending_operation.put_rejection_candidate=@{error_log_basename=[IO.Path]::GetFileName([string]$_.Exception.Data['NativeLogPath']);command_id=$commandId;body_sha256=(Get-WorkshopFileHash $bodyPath)};Save-Run } throw } finally { # Protected command bodies stay in the protected run directory for interrupted-operation reconciliation. $secrets=$null; $body=$null } } function Restart-OwnedVm([string]$Machine) { Invoke-Az @('vm','restart','--ids',(Output-Value ($Machine+'_vm_id'))) 600 | Out-Null Start-Sleep -Seconds 20 } function Initialize-OwnedDomain { for ($attempt=0;$attempt -lt 4;$attempt++) { $promotion=Invoke-Guest 'dc' 'PromoteDomain' if ($promotion.ready -is [bool] -and $promotion.ready) {return $promotion} if ($promotion.rebootRequired -isnot [bool] -or -not $promotion.rebootRequired) {throw 'Domain promotion is neither ready nor requesting its recorded restart.'} if ($attempt -ge 3) {throw 'Domain promotion exceeded its bounded three-restart limit.'} Restart-OwnedVm 'dc' } throw 'Domain promotion did not reach a positively verified ready state.' } function Set-PhaseVariables([string]$SelectedPhase) { $secrets=Unprotect-AzureFilesSecrets -Path $secretPath $vars=@{prefix=$script:state.prefix;location=$script:state.location;vnet_cidr=$script:state.vnet_cidr;image_urn=$script:state.image_urn;admin_username='awzadmin';admin_password=$secrets.vmAdminPassword;identity_enabled=($SelectedPhase -eq 'Identity');domain_name='awzfiles.test';netbios_domain_name='AWZFILES'} if ($SelectedPhase -eq 'Identity') { if (-not $script:state.domain_facts) { throw 'Bootstrap must record real AD domain and storage object facts before the identity plan.' } $vars.domain_guid=$script:state.domain_facts.domainGuid; $vars.domain_sid=$script:state.domain_facts.domainSid $vars.storage_object_sids=@{}; $vars.storage_sam_account_names=$script:state.storage_sam_names foreach ($account in $script:state.domain_facts.storageAccounts) { $vars.storage_object_sids[$account.role]=$account.sid } } Write-WorkshopJson -Path $tfvars -Value $vars } function Test-ProtectedBaseline { $current=@((Invoke-Az @('resource','list')).id) $missing=@($script:state.baseline_resource_ids|Where-Object {$_ -notin $current}) return @{missing=$missing.Count;preserved=($missing.Count -eq 0);protected_count=$script:state.baseline_resource_ids.Count} } function Get-ConfigurationAudit { $accounts=@{} foreach ($role in @('control','exercise')) { $account=$script:state.outputs.storage_accounts[$role] $arm=Invoke-Az @('storage','account','show','--name',$account.name,'--resource-group',(Output-Value 'resource_group_name')) $smb=Invoke-Az @('storage','account','file-service-properties','show','--account-name',$account.name,'--resource-group',(Output-Value 'resource_group_name')) $accounts[$role]=@{account=$arm;smb=$smb} } $snapshot=@{utc=[DateTimeOffset]::UtcNow.ToString('o');accounts=$accounts} Write-WorkshopJson -Path (Join-Path $privateDirectory 'evidence/configuration-latest.json') -Value $snapshot return $snapshot } function Set-ShareGate([string]$Permission) { if ($Permission -notin @('StorageFileDataSmbShareReader','StorageFileDataSmbShareElevatedContributor')) {throw 'Unsupported owned share gate.'} $account=$script:state.outputs.storage_accounts.exercise Invoke-Az @('storage','account','update','--resource-group',(Output-Value 'resource_group_name'),'--name',$account.name,'--default-share-permission',$Permission) | Out-Null } function Set-OwnedStorageIdentity { param([ValidateSet('control','exercise')][string]$Role) $account=$script:state.outputs.storage_accounts[$Role] $records=@($script:state.domain_facts.storageAccounts|Where-Object {$_.role -eq $Role}) if ($records.Count -ne 1) {throw 'Exactly one observed owned AD storage identity is required.'} $expected=@{domainName=$script:state.domain_facts.domainName;forestName=$script:state.domain_facts.forestName;domainGuid=$script:state.domain_facts.domainGuid;domainSid=$script:state.domain_facts.domainSid;netBiosDomainName=$script:state.domain_facts.netbiosName;azureStorageSid=$records[0].sid} $before=Invoke-Az @('storage','account','show','--name',$account.name,'--resource-group',(Output-Value 'resource_group_name')) $identity=$before.azureFilesIdentityBasedAuthentication;$ad=$identity.activeDirectoryProperties if ($identity.directoryServiceOptions -ne 'AD' -or $identity.defaultSharePermission -ne 'StorageFileDataSmbShareElevatedContributor') {throw 'Storage identity differs from the reviewed Identity plan.'} foreach ($key in $expected.Keys) {if ($ad[$key] -ne $expected[$key]) {throw 'A modeled AD domain property differs from its reviewed observation; refusing helper overwrite.'}} $sam=$script:state.storage_sam_names[$Role] if (($ad.samAccountName -and $ad.samAccountName -cne $sam) -or ($ad.accountType -and $ad.accountType -cne 'Computer')) {throw 'An unmodeled AD identity field already has an unexpected value.'} # CLI 2.88 ignores SAM/type alone. Its enable-AD branch requires the complete # previously reviewed domain tuple and an explicit preserved default share gate. $identityArgs=@('storage','account','update','--resource-group',(Output-Value 'resource_group_name'),'--name',$account.name, '--enable-files-adds','true','--domain-name',$expected.domainName,'--forest-name',$expected.forestName,'--domain-guid',$expected.domainGuid, '--domain-sid',$expected.domainSid,'--net-bios-domain-name',$expected.netBiosDomainName,'--azure-storage-sid',$expected.azureStorageSid, '--sam-account-name',$sam,'--account-type','Computer','--default-share-permission',$identity.defaultSharePermission) $after=Invoke-Az $identityArgs $newIdentity=$after.azureFilesIdentityBasedAuthentication;$newAd=$newIdentity.activeDirectoryProperties if ($newIdentity.directoryServiceOptions -ne 'AD' -or $newIdentity.defaultSharePermission -ne $identity.defaultSharePermission -or $newAd.samAccountName -cne $sam -or $newAd.accountType -cne 'Computer') {throw 'Storage SAM/type update was not positively observed.'} foreach ($key in $expected.Keys) {if ($newAd[$key] -ne $expected[$key]) {throw 'Modeled AD properties changed unexpectedly during the narrow identity helper.'}} } function Get-FileLogs { $request=$script:state.probe_requests[-1] if ($request.nonceHash -cnotmatch '^[a-f0-9]{64}$') {throw 'A recorded probe nonce is required for log correlation.'} $start=[DateTimeOffset]::Parse($request.requested_utc).UtcDateTime.ToString('o') $marker='probe-Writer-'+$request.nonceHash.Substring(0,12)+'.tmp' $query="StorageFileLogs | where TimeGenerated >= datetime($start) | where AccountName in ('$($script:state.outputs.storage_accounts.control.name)','$($script:state.outputs.storage_accounts.exercise.name)') | where Protocol =~ 'SMB' | where Uri contains '$marker' or ObjectKey contains '$marker' | project TimeGenerated, AccountName, OperationName, Protocol, AuthenticationType, StatusCode, StatusText, CorrelationId, Uri, SmbCommandMajor, SmbStatusCode, SmbPrimarySID, SmbSessionID, SmbMessageID, ObjectKey | order by TimeGenerated desc | take 2000" $limit=[DateTimeOffset]::UtcNow.AddSeconds($LogWaitSeconds) do { $rows=@();$queryError='' try {$rows=@(Invoke-AzureFilesLogsQuery -WorkspaceId (Output-Value 'workspace_id') -Query $query -PrivateDirectory $privateDirectory -SubscriptionId $script:state.subscription_id -TimeoutSeconds 180)} catch { # New workspaces may not expose this table until the first diagnostic batch arrives. $privateError=[string]$_.Exception.Data['NativeStdErr'] if ($privateError -notmatch '(?is)(failed to resolve table(?: or column)? expression named\s*[''"]StorageFileLogs[''"]|[''"]StorageFileLogs[''"]\s+does not refer to any known table)') {throw} $queryError='StorageFileLogs table has not been created by ingestion yet.' } $correlation=Test-AzureFilesLogCorrelation -Rows $rows -State $script:state Write-WorkshopJson -Path (Join-Path $privateDirectory 'evidence/storage-log-poll.json') -Value @{utc=[DateTimeOffset]::UtcNow.ToString('o');count=$rows.Count;correlation=$correlation;query_error=$queryError} if ($correlation.passed) { Write-WorkshopJson -Path (Join-Path $privateDirectory 'evidence/storage-file-logs.json') -Value $rows $script:state.file_logs=@{count=@($rows).Count;collected_utc=[DateTimeOffset]::UtcNow.ToString('o')}; Save-Run return $rows } if ([DateTimeOffset]::UtcNow -ge $limit) {throw ('Current user-test StorageFileLogs were not corroborated inside the bounded collection window: '+($correlation.observed.failure_reasons -join ', '))} Start-Sleep -Seconds 30 } while ($true) } function Get-RetainedKerberosCorrelation { [CmdletBinding()] param([Parameter(Mandatory)][hashtable]$State,[Parameter(Mandatory)][string]$PrivateDirectory) # This is a local evaluation of genuine retained reports, not a fresh TGS # request. Preserve source/event UTC separately from actual evaluation UTC. $parseUtc={param($Value) $parsed=[DateTimeOffset]::MinValue if([string]$Value -notmatch '(?:Z|\+00:00)$' -or -not [DateTimeOffset]::TryParse([string]$Value,[ref]$parsed) -or $parsed.Offset -ne [TimeSpan]::Zero){throw 'Retained Kerberos evidence requires explicit UTC timestamps.'} return $parsed } $evaluated=[DateTimeOffset]::UtcNow $created=& $parseUtc $State.created_utc $deadline=$created.AddHours(6) $scope='/subscriptions/'+$State.subscription_id+'/resourceGroups/'+$State.prefix+'-rg' $services=@{} foreach($role in @('control','exercise')){ $account=$State.outputs.storage_accounts[$role] $expected=$scope+'/providers/Microsoft.Storage/storageAccounts/'+$account.name $sam=[string]$State.storage_sam_names[$role] if($State.prefix -cnotmatch '^awzf[0-9a-f]{10}$' -or $account.name -cnotmatch '^[a-z0-9]{3,24}$' -or $account.id -ine $expected -or $account.id -notin $State.owned_ids -or $account.id -in $State.baseline_resource_ids -or $sam -cnotmatch '^[A-Za-z0-9-]{3,15}$'){throw 'Kerberos evidence identities are not exact recorded owned storage accounts.'} $services[$role]=@(('cifs/'+$account.name+'.file.core.windows.net'),$sam,($sam+'$')) } if([string]$State.domain_facts.domainSid -notmatch '^S-1-5-21-\d+-\d+-\d+$'){throw 'An observed domain SID is required for retained Kerberos evidence.'} $faults=@($State.operations|Where-Object {$_.stage -ceq 'Fault' -and $_.scenario -ceq 'Kerberos'}|Sort-Object {$_.started_utc}) $faultStart=$null;$repairStart=$null if($faults.Count){ $faultStart=& $parseUtc $faults[0].started_utc $repairs=@($State.operations|Where-Object {$_.stage -ceq 'Repair' -and $_.scenario -ceq 'Kerberos' -and (& $parseUtc $_.started_utc) -gt $faultStart}|Sort-Object {$_.started_utc}) if($repairs.Count){$repairStart=& $parseUtc $repairs[0].started_utc} } $latestRequest=& $parseUtc $State.probe_requests[-1].requested_utc $sources=@();$events=@() foreach($source in @($State.guest_reports|Where-Object {$_.action -ceq 'Audit' -and $_.machine -ceq 'dc'})){ if([IO.Path]::GetFileName([string]$source.file) -cne $source.file -or $source.file -cnotmatch '^[A-Za-z0-9_.-]+\.json$' -or $source.sha256 -cnotmatch '^[a-f0-9]{64}$'){throw 'A retained DC source reference is malformed.'} $path=Join-Path $PrivateDirectory ('evidence/'+$source.file) $bytes=[IO.File]::ReadAllBytes($path) $hash=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() if($hash -cne $source.sha256){throw 'A retained DC Audit hash changed; Kerberos correlation is prohibited.'} $envelope=[Text.Encoding]::UTF8.GetString($bytes)|ConvertFrom-Json -AsHashtable -Depth 100 -DateKind String if($envelope.ok -isnot [bool] -or -not $envelope.ok -or $envelope.action -cne 'Audit' -or $envelope.runId -cne $State.run_id -or $envelope.result.domainSid -cne $State.domain_facts.domainSid){throw 'A retained DC Audit does not match this run, action and observed domain.'} $reportUtc=& $parseUtc $envelope.utc;$retainedUtc=& $parseUtc $source.utc if($reportUtc -lt $created -or $reportUtc -gt $deadline -or $retainedUtc -lt $reportUtc -or $retainedUtc -gt $deadline -or $retainedUtc -gt $evaluated){throw 'A retained DC Audit is outside the original run window.'} $sources+=@{sha256=$hash;report_utc=$reportUtc.UtcDateTime.ToString('o');retained_utc=$retainedUtc.UtcDateTime.ToString('o')} foreach($ticketEvent in @($envelope.result.observedStorageEvents)){ $eventUtc=& $parseUtc $ticketEvent.utc if($eventUtc -lt $created -or $eventUtc -gt $reportUtc){throw 'A retained Kerberos event is outside its genuine source time window.'} $roles=@(@('control','exercise')|Where-Object {$ticketEvent.serviceName -in $services[$_]}) if($roles.Count -ne 1 -or $ticketEvent.eventId -ne 4769){continue} $events+=@{utc=$eventUtc;role=$roles[0];status=[string]$ticketEvent.status;encryption=[string]$ticketEvent.ticketEncryptionType;source_sha256=$hash;source_report_utc=$reportUtc} } } $unknown=@($events|Where-Object {$_.role -ceq 'exercise' -and $_.status -eq '0x7' -and $faultStart -and $repairStart -and $_.utc -ge $faultStart -and $_.utc -lt $repairStart}|Sort-Object {$_.utc},{$_.role},{$_.status},{$_.encryption} -Unique) $latestReport=if($sources.Count){@($sources|Sort-Object {$_.report_utc})[-1].report_utc}else{$null} $healthy=@($events|Where-Object {$_.role -ceq 'exercise' -and $_.status -eq '0x0' -and $_.encryption -eq '0x12' -and $_.source_report_utc.UtcDateTime.ToString('o') -ceq $latestReport -and $_.utc -ge $latestRequest}|Sort-Object {$_.utc},{$_.role},{$_.status},{$_.encryption} -Unique) $reasons=@() if(-not $faultStart -or -not $repairStart){$reasons+='recorded-kerberos-fault-repair-window-missing'} if(-not $unknown.Count){$reasons+='retained-exact-exercise-4769-status-0x7-missing'} if(-not $healthy.Count){$reasons+='latest-audit-current-request-exercise-aes256-success-missing'} $selected=@($unknown|Sort-Object {$_.utc}|Select-Object -First 12)+@($healthy|Sort-Object {$_.utc}|Select-Object -First 12) return @{passed=($reasons.Count -eq 0);observed=@{evidence_mode='retained-dc-audit-evaluation';evaluated_utc=$evaluated.ToString('o');failure_reasons=$reasons;principal_unknown_count=$unknown.Count;latest_healthy_exercise_aes256_count=$healthy.Count; current_probe_requested_utc=$latestRequest.UtcDateTime.ToString('o');fault_started_utc=$(if($faultStart){$faultStart.UtcDateTime.ToString('o')}else{$null});first_repair_started_utc=$(if($repairStart){$repairStart.UtcDateTime.ToString('o')}else{$null}); events=@($selected|ForEach-Object {@{event_utc=$_.utc.UtcDateTime.ToString('o');event_id=4769;service_role=$_.role;status=$_.status;ticket_encryption_type=$_.encryption;source_sha256=$_.source_sha256;source_report_utc=$_.source_report_utc.UtcDateTime.ToString('o')}})}} } function Get-CompletionAudit { $required=@('preflight','deploy-Foundation','bootstrap','deploy-Identity','configure','baseline','final-healthy','terraform-no-drift','storage-log-correlation','kerberos-event-correlation','cleanup-absence','cleanup-repeat','baseline-preserved') foreach ($s in @('Kerberos','ShareGate','Ntfs')) {$required+=@("fault-$s","recovery-$s","repeated-recovery-$s")} $missing=@($required|Where-Object {$requiredName=$_;$latest=@($script:state.checks|Where-Object {$_.name -eq $requiredName});-not $latest.Count -or $latest[-1].passed -isnot [bool] -or -not $latest[-1].passed}) if ($script:state.pending_operation) {$missing+='pending-operation'} if ($script:state.active_fault) {$missing+='active-fault'} if ($script:state.cleanup_count -lt 2) {$missing+='cleanup-count'} $cleanup=@($script:state.operations|Where-Object {$_.stage -eq 'Cleanup'}) $absence=@($script:state.checks|Where-Object {$_.name -eq 'cleanup-absence' -and $_.passed}) $repeat=@($script:state.checks|Where-Object {$_.name -eq 'cleanup-repeat' -and $_.passed}) $timePassed=$cleanup.Count -gt 0 -and $absence.Count -gt 0 -and $repeat.Count -gt 0 if ($timePassed) { $created=[DateTimeOffset]::Parse($script:state.created_utc);$stop=$created.AddHours(4.5);$deadline=$created.AddHours(6) $started=[DateTimeOffset]::Parse($cleanup[0].started_utc);$absent=[DateTimeOffset]::Parse($absence[-1].utc);$repeated=[DateTimeOffset]::Parse($repeat[-1].utc) $timePassed=[math]::Abs(([DateTimeOffset]::Parse($script:state.exercise_stop_utc)-$stop).TotalSeconds) -le 1 -and [math]::Abs(([DateTimeOffset]::Parse($script:state.cleanup_deadline_utc)-$deadline).TotalSeconds) -le 1 -and $started -ge $created -and $started -le $stop -and $absent -ge $started -and $absent -le $deadline -and $repeated -ge $absent -and $repeated -le $deadline } if (-not $timePassed) {$missing+='operating-time-limit'} if ($script:state.cost.active_estimate -gt 15 -or $script:state.cost.cleanup_reserve -lt 5 -or $script:state.cost.total_limit -ne 20) {$missing+='operating-cost-limit'} return @{complete=($missing.Count -eq 0);missing_checks=$missing;required_checks=$required} } function Export-SanitizedEvidence { $audit=Get-CompletionAudit $userEvidence=@();$kerberosEvents=@();$windowsSmbEvents=@() foreach ($source in @($script:state.guest_reports|Where-Object {$_.action -in @('Probe','Audit')})) { $path=Join-Path $privateDirectory ('evidence/'+$source.file) if ((Get-WorkshopFileHash $path) -ne $source.sha256) {throw 'A retained guest evidence file changed; public export is prohibited.'} $envelope=Get-Content -LiteralPath $path -Raw|ConvertFrom-Json -AsHashtable -Depth 100 -DateKind String if ($source.action -eq 'Audit' -and $envelope.result.ContainsKey('observedStorageEvents')) { foreach ($kerberosEvent in @($envelope.result.observedStorageEvents)) {$kerberosEvents+=@{utc=$kerberosEvent.utc;service_name=$kerberosEvent.serviceName;status=$kerberosEvent.status;ticket_encryption_type=$kerberosEvent.ticketEncryptionType}} } if ($source.action -eq 'Audit' -and $envelope.result.ContainsKey('windowsSmbEvents')) { foreach ($smbEvent in @($envelope.result.windowsSmbEvents)) {$windowsSmbEvents+=@{event_id=$smbEvent.eventId;utc=$smbEvent.utc;server=$smbEvent.server;channel=$smbEvent.channel}} } if ($source.action -ne 'Probe') {continue} $actors=@() foreach ($actor in @($envelope.result.reports)) { $targets=@() foreach ($target in @($actor.targets)) { $operations=@{skipped=$target.operations.skipped} foreach ($operation in @('read','create','modify','delete','readCreated','deleteCreated','writeDac')) { if ($target.operations.ContainsKey($operation)) {$operations[$operation]=@{ok=$target.operations[$operation].ok;code=$target.operations[$operation].code}} } $targets+=@{account_role=$target.accountRole;folder=$target.folder;private_dns=$target.network.dnsPrivate;tcp445=$target.network.tcp445; ticket=@{ok=$target.ticket.ok;exit_code=$target.ticket.exitCode;aes256=$target.ticket.aes256;principal_unknown=$target.ticket.principalUnknown; diagnostic_code=$(if($target.ticket.ContainsKey('diagnosticCode')){$target.ticket.diagnosticCode}else{$null}); get_ticket_substatus=$(if($target.ticket.ContainsKey('getTicketSubstatus')){$target.ticket.getTicketSubstatus}else{$null}); target_unavailable_status=$(if($target.ticket.ContainsKey('targetUnavailableStatus')){$target.ticket.targetUnavailableStatus}else{$null})}; mount=@{ok=$target.mount.ok;exit_code=$target.mount.exitCode; timed_out=$(if($target.mount.ContainsKey('timedOut')){$target.mount.timedOut}else{$null}); native_exit_code=$(if($target.mount.ContainsKey('nativeExitCode')){$target.mount.nativeExitCode}else{$null}); system_error_code=$(if($target.mount.ContainsKey('systemErrorCode')){$target.mount.systemErrorCode}else{$null})}; smb=@{found=$target.smb.found;dialect=$(if($target.smb.ContainsKey('dialect')){$target.smb.dialect}else{''});encrypted=$(if($target.smb.ContainsKey('encrypted')){$target.smb.encrypted}else{$null})}; operations=$operations;marker_hash_matches=$target.hashMatches;created_content_hash_matches=$target.createdHashMatches} } $actors+=@{actor=$actor.identity.actor;logon_luid=$actor.identity.luid;expected_identity=$actor.identity.expectedIdentity;tickets_purged=$actor.identity.purged;utc=$actor.utc;targets=$targets} } $userEvidence+=@{utc=$envelope.utc;nonce_sha256=$envelope.result.nonceHash;source_sha256=$source.sha256;fixtures_restored=$envelope.result.fixturesRestored;actors=$actors} } $fileEvidence=@();$fileLogPath=Join-Path $privateDirectory 'evidence/storage-file-logs.json' if (Test-Path -LiteralPath $fileLogPath) { $writer=@($script:state.domain_facts.users|Where-Object {$_.sam -eq 'Writer'}) foreach ($row in @(Get-Content -LiteralPath $fileLogPath -Raw|ConvertFrom-Json -AsHashtable -Depth 50 -DateKind String)) { $role=if($row.AccountName -eq $script:state.outputs.storage_accounts.control.name){'control'}elseif($row.AccountName -eq $script:state.outputs.storage_accounts.exercise.name){'exercise'}else{'unrecognized'} $probeFile=[regex]::Match(([string]$row.Uri+' '+[string]$row.ObjectKey),'probe-Writer-[a-f0-9]{12}\.tmp').Value $fileEvidence+=@{utc=$row.TimeGenerated;account_role=$role;protocol=$row.Protocol;authentication_type=$row.AuthenticationType;probe_file=$probeFile;operation_name=$row.OperationName;smb_command_major=$row.SmbCommandMajor;smb_status_code=$row.SmbStatusCode;smb_session_id=$row.SmbSessionID;smb_message_id=$row.SmbMessageID;writer_sid_matches=($writer.Count -eq 1 -and $row.SmbPrimarySID -eq $writer[0].sid)} } } $report=@{schema_version=1;workshop='Azure Files AD DS';recorded_date=[DateTime]::UtcNow.ToString('yyyy-MM-dd');complete=$audit.complete;missing_checks=$audit.missing_checks; operating_limit=@{active_estimate_eur=$script:state.cost.active_estimate;cleanup_reserve_eur=5;total_eur=20;hours=6;created_utc=$script:state.created_utc;exercise_stop_utc=$script:state.exercise_stop_utc;cleanup_deadline_utc=$script:state.cleanup_deadline_utc;cleanup_started_utc=@($script:state.operations|Where-Object {$_.stage -eq 'Cleanup'}|Select-Object -First 1|ForEach-Object {$_.started_utc})}; fault_counts=$script:state.fault_counts;repair_counts=$script:state.repair_counts;cleanup_count=$script:state.cleanup_count;protected_resource_count=$script:state.baseline_resource_ids.Count; checks=@($script:state.checks|ForEach-Object {@{utc=$_.utc;name=$_.name;passed=$_.passed;scenario=$_.scenario;phase=$_.phase;observed=$_.observed}}); guest_sources=@($script:state.guest_reports|ForEach-Object {@{action=$_.action;machine=$_.machine;utc=$_.utc;sha256=$_.sha256}}); user_session_evidence=$userEvidence;kerberos_events=@($kerberosEvents|Sort-Object {$_.utc},{$_.service_name},{$_.status} -Unique);storage_smb_evidence=$fileEvidence;windows_smb_events=$windowsSmbEvents; bootstrap_dependency_proof=$(if($script:state.domain_facts -and $script:state.domain_facts.ContainsKey('azurePowerShellProof')){$script:state.domain_facts.azurePowerShellProof}else{$null})} $json=$report|ConvertTo-Json -Depth 80 foreach ($sensitive in @($script:state.subscription_id,$script:state.tenant_id,$privateDirectory)) {if($sensitive){$json=$json.Replace($sensitive,'[REDACTED]')}} if ($script:state.outputs.ContainsKey('storage_accounts')) { foreach ($role in @('control','exercise')) {$json=$json.Replace($script:state.outputs.storage_accounts[$role].name,$role.ToUpperInvariant()+'_ACCOUNT')} } $destination=Join-Path $PSScriptRoot 'docs/evidence/sanitized-evidence.json' [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null [IO.File]::WriteAllText($destination,$json,[Text.UTF8Encoding]::new($false)) Write-Host ("Sanitized acceptance complete={0}; missing checks={1}" -f $audit.complete,$audit.missing_checks.Count) } try { if (Test-Path -LiteralPath $StatePath) { $script:state=Get-Content -LiteralPath $StatePath -Raw|ConvertFrom-Json -AsHashtable -Depth 100 -DateKind String } if (-not $script:state -and $Stage -ne 'Preflight') { throw 'Start with Preflight and a new protected absolute StatePath.' } if ($script:state) { Assert-WorkshopContext -State $script:state -PrivateDirectory $privateDirectory -ExpectedSubscriptionId $SubscriptionId | Out-Null $env:ARM_SUBSCRIPTION_ID=$script:state.subscription_id; $env:ARM_TENANT_ID=$script:state.tenant_id } if ($Stage -in @('Fault','Repair') -and -not $Scenario) { throw 'Fault and Repair require an explicit Scenario.' } switch ($Stage) { 'Preflight' { $account=Assert-WorkshopContext -State $script:state -PrivateDirectory $privateDirectory -ExpectedSubscriptionId $SubscriptionId if (-not $script:state) { if (-not $CaptureAccessVerified) { throw 'Verify usable Portal and CLI screenshot access, then pass -CaptureAccessVerified before the first Preflight.' } $runId=[guid]::NewGuid().ToString('N').Substring(0,10);$prefix='awzf'+$runId $script:state=@{schema_version=1;run_id=$runId;prefix=$prefix;subscription_id=$account.id;tenant_id=$account.tenantId;location=$Location;vnet_cidr=$VnetCidr;image_urn=$ImageUrn; created_utc=[DateTimeOffset]::UtcNow.ToString('o');exercise_stop_utc=[DateTimeOffset]::UtcNow.AddHours(4.5).ToString('o');cleanup_deadline_utc=[DateTimeOffset]::UtcNow.AddHours(6).ToString('o'); baseline_resource_ids=@();owned_ids=@();outputs=@{};checks=@();operations=@();pending_operation=$null;guest_commands=@();guest_reports=@();active_fault='';fault_counts=@{};repair_counts=@{};baseline_passes=@();plans=@{};domain_facts=$null;configured=$false;cleanup_count=0;capture_access_verified=$true; deployed_phases=@{};probe_requests=@();probe_luids=@{};storage_sam_names=@{control=('afc'+$runId);exercise=('afe'+$runId)} } $script:state.baseline_resource_ids=@((Invoke-Az @('resource','list')).id) $credentials=@{vmAdminPassword=(New-AzureFilesPassword);dsrmPassword=(New-AzureFilesPassword);labAdminPassword=(New-AzureFilesPassword);readerPassword=(New-AzureFilesPassword);writerPassword=(New-AzureFilesPassword);outsiderPassword=(New-AzureFilesPassword);storageKerbKeys=@{}} Protect-AzureFilesSecrets -Secrets $credentials -Path $secretPath; Save-Run } foreach ($provider in @('Microsoft.Compute','Microsoft.Network','Microsoft.Storage','Microsoft.OperationalInsights','Microsoft.Insights','Microsoft.ManagedIdentity')) { $registered=Invoke-Az @('provider','show','--namespace',$provider) if ($registered.registrationState -ne 'Registered') { throw "Provider $provider is not registered. Preflight does not register it automatically." } } $permissions=Invoke-Az @('rest','--method','get','--url',("https://management.azure.com/subscriptions/$($account.id)/providers/Microsoft.Authorization/permissions?api-version=2022-04-01")) foreach ($action in @('Microsoft.Resources/subscriptions/resourceGroups/write','Microsoft.Compute/virtualMachines/write','Microsoft.Compute/virtualMachines/runCommands/write','Microsoft.Storage/storageAccounts/listKeys/action','Microsoft.Storage/storageAccounts/regenerateKey/action','Microsoft.Network/bastionHosts/write','Microsoft.Authorization/roleAssignments/write','Microsoft.Authorization/roleAssignments/delete','Microsoft.ManagedIdentity/userAssignedIdentities/read','Microsoft.ManagedIdentity/userAssignedIdentities/write','Microsoft.ManagedIdentity/userAssignedIdentities/delete','Microsoft.ManagedIdentity/userAssignedIdentities/assign/action','Microsoft.Resources/subscriptions/resourceGroups/delete')) { if (-not (Test-WorkshopActionAllowed -Permissions $permissions.value -Action $action)) { throw "Missing required operation: $action" } } $policies=Invoke-Az @('policy','assignment','list','--scope',('/subscriptions/'+$account.id),'--disable-scope-strict-match','true') $locks=Invoke-Az @('lock','list') if(@($locks|Where-Object {$_.id -match '^/subscriptions/[^/]+/providers/Microsoft.Authorization/locks/[^/]+$'}).Count){throw 'A subscription-level management lock prevents a guaranteed lab cleanup.'} Write-WorkshopJson -Path (Join-Path $privateDirectory 'governance.json') -Value @{permissions=$permissions;policies=$policies;locks=$locks} foreach ($vnet in @(Invoke-Az @('network','vnet','list'))) { if ($vnet.id -in $script:state.owned_ids) { continue } foreach ($cidr in $vnet.addressSpace.addressPrefixes) { if (Test-WorkshopCidrOverlap -First $script:state.vnet_cidr -Second $cidr) { throw 'The proposed address range overlaps an existing VNet.' } } } $usage=Invoke-Az @('vm','list-usage','--location',$Location) $needed=if($script:state.outputs.ContainsKey('dc_vm_id')){0}else{4} foreach ($quotaName in @('cores','standardBSFamily')) { $quota=@($usage|Where-Object {$_.name.value -eq $quotaName}) if ($quota.Count -ne 1 -or ([int]$quota[0].limit-[int]$quota[0].currentValue) -lt $needed) { throw "Insufficient $quotaName quota." } } $skus=Invoke-Az @('vm','list-skus','--location',$Location,'--size','Standard_B2s','--all') $sku=@($skus|Where-Object {$_.name -eq 'Standard_B2s'}) if ($sku.Count -ne 1 -or @($sku[0].restrictions).Count) { throw 'B2s is restricted in the selected region.' } $image=Invoke-Az @('vm','image','show','--location',$Location,'--urn',$script:state.image_urn) if ($image.name -ne $script:state.image_urn.Split(':')[3]) { throw 'Exact pinned Windows image could not be verified.' } $version=Invoke-Az @('version') if ($version.'azure-cli' -ne '2.88.0' -or $PSVersionTable.PSVersion.ToString() -ne '7.6.9') { throw 'Use the pinned PowerShell7.6.9 and AzureCLI2.88.0 versions for this recorded package.' } $tfVersion=(Invoke-Tf @('version','-json')).StdOut|ConvertFrom-Json if ($tfVersion.terraform_version -ne '1.15.8') { throw 'Terraform1.15.8 is required.' } $script:state.cost=Get-AzureFilesBudgetEstimate -Location $Location; Save-Run Add-Check 'preflight' $true @{b2s_vms=2;image_version=$image.name;active_estimate_eur=$script:state.cost.active_estimate;protected_resources=$script:state.baseline_resource_ids.Count;capture_access=$true;policy_assignments=@($policies).Count;locks=@($locks).Count} Write-Host ('Preflight passed. Six-hour estimate EUR{0}; cleanup reserve EUR5. No Azure resource created.' -f $script:state.cost.active_estimate) } 'Plan' { if ($script:state.pending_operation) { $pending=$script:state.pending_operation $deployResume=$pending.stage -eq 'Deploy' -and $pending.phase -eq $Phase $failed=if($script:state.ContainsKey('last_failure')){$script:state.last_failure}else{$null} $bootstrapFoundationResume=$Phase -eq 'Foundation' -and $pending.stage -eq 'Bootstrap' -and $pending.phase -eq 'Foundation' -and $failed -and $failed.stage -eq 'Bootstrap' -and $failed.phase -eq $pending.phase -and $failed.scenario -eq $pending.scenario -and [DateTimeOffset]::Parse($failed.utc) -ge [DateTimeOffset]::Parse($pending.started_utc) if (-not $Resume -or -not ($deployResume -or $bootstrapFoundationResume)) {throw 'Reconcile the interrupted operation before planning.'} Assert-InterruptedOperationSettled;Sync-Ownership $script:state.pending_operation.reconciled_utc=[DateTimeOffset]::UtcNow.ToString('o') $script:state.pending_operation.reconciled_by='Reviewed '+$Phase+' plan after terminal operation; no bootstrap success inferred' $script:state.operations+=$script:state.pending_operation;$script:state.pending_operation=$null;Save-Run } if ($script:state.active_fault) { throw 'Repair the active fault before checking Terraform consistency.' } if ($Phase -eq 'Foundation' -and $script:state.deployed_phases.ContainsKey('Identity')) {throw 'The identity phase has been deployed. Use -Phase Identity to preserve AD authentication.'} Set-PhaseVariables $Phase Invoke-Tf @('init','-input=false','-lockfile=readonly') | Out-Null Invoke-Tf @('validate') | Out-Null $file=Join-Path $privateDirectory ($Phase.ToLowerInvariant()+'.tfplan') $planResult=Invoke-Tf @('plan','-input=false','-detailed-exitcode',"-state=$tfstate","-var-file=$tfvars","-out=$file") -AllowFailure if ($planResult.ExitCode -notin @(0,2)) { throw 'Terraform plan failed; inspect the private journal.' } $plan=(Invoke-Tf @('show','-json',$file)).StdOut|ConvertFrom-Json -AsHashtable -Depth 100 foreach ($change in @($plan.resource_changes)) { if ($change.mode -ne 'managed') { continue } if ('delete' -in $change.change.actions) { throw 'Deployment plans may not delete or replace resources.' } if ($change.change.before -and $change.change.before.ContainsKey('id')) { $id=[string]$change.change.before.id if ($change.type -eq 'azurerm_monitor_diagnostic_setting') {$id=$id.Split('|')[0]} if (-not (Test-AzureFilesOwnedId -Id $id -Scope (Get-Scope) -Baseline $script:state.baseline_resource_ids)) { throw 'Plan touches an unowned resource.' } } } $script:state.plans[$Phase]=@{path=$file;sha256=(Get-WorkshopFileHash $file);vars_sha256=(Get-WorkshopFileHash $tfvars);exit_code=$planResult.ExitCode;utc=[DateTimeOffset]::UtcNow.ToString('o')}; Save-Run Write-Host ("Reviewed-plan target {0}: exit={1}, SHA256={2}" -f $Phase,$planResult.ExitCode,$script:state.plans[$Phase].sha256) if ($script:state.configured -and $Phase -eq 'Identity') { Add-Check 'terraform-no-drift' ($planResult.ExitCode -eq 0) @{exit_code=$planResult.ExitCode} 'final' } } 'Deploy' { Assert-Mutation if (-not $ReviewedPlan -or -not $AcknowledgeCost -or -not $script:state.plans.ContainsKey($Phase)) { throw 'Deploy requires -AcknowledgeCost -ReviewedPlan and the saved phase plan.' } if ([DateTimeOffset]::UtcNow -gt [DateTimeOffset]::Parse($script:state.cost.utc).AddMinutes(60)) { throw 'Price estimate expired; refresh Preflight and review a new plan.' } $plan=$script:state.plans[$Phase] if ((Get-WorkshopFileHash $plan.path) -ne $plan.sha256 -or (Get-WorkshopFileHash $tfvars) -ne $plan.vars_sha256) { throw 'Saved plan or its variables changed after review.' } Start-Operation ('deploy-'+$Phase) try { Invoke-Tf @('apply','-input=false',"-state=$tfstate",$plan.path) 2400 | Out-Null } finally { Sync-Ownership } $script:state.deployed_phases[$Phase]=[DateTimeOffset]::UtcNow.ToString('o'); Complete-Operation; Add-Check ('deploy-'+$Phase) $true @{owned_ids=$script:state.owned_ids.Count} } 'Bootstrap' { Assert-Mutation; Sync-Ownership; Assert-OwnedMembers; Start-Operation 'bootstrap' $null=Initialize-OwnedDomain $domainReport=Invoke-Guest 'dc' 'InitializeDomain' $script:state.bootstrap_domain=$domainReport;Save-Run $join=Invoke-Guest 'client' 'JoinClient' if ($join.rebootRequired) { Restart-OwnedVm 'client'; $null=Invoke-Guest 'client' 'JoinClient' } $credentials=Unprotect-AzureFilesSecrets $secretPath foreach ($role in @('control','exercise')) { $account=$script:state.outputs.storage_accounts[$role] if (-not $credentials.storageKerbKeys.ContainsKey($account.name)) { $keys=Invoke-Az @('storage','account','keys','renew','--resource-group',(Output-Value 'resource_group_name'),'--account-name',$account.name,'--key','primary','--key-type','kerb') $kerb=@($keys|Where-Object {$_.keyName -eq 'kerb1'}) if (-not $kerb.Count) { $keys=Invoke-Az @('storage','account','keys','list','--resource-group',(Output-Value 'resource_group_name'),'--account-name',$account.name,'--expand-key-type','kerb');$kerb=@($keys|Where-Object {$_.keyName -eq 'kerb1'}) } if ($kerb.Count -ne 1) { throw 'The exact Kerb1 registration key was not returned.' } $credentials.storageKerbKeys[$account.name]=$kerb[0].value;Protect-AzureFilesSecrets $credentials $secretPath } } $script:state.domain_facts=Invoke-Guest 'dc' 'RegisterStorage' $script:state.domain_facts.users=$domainReport.users $script:state.domain_facts.groups=$domainReport.groups $script:state.configure_client_report=Invoke-Guest 'client' 'ConfigureClient';Save-Run Sync-Ownership; Complete-Operation; Add-Check 'bootstrap' $true @{domain='awzfiles.test';users=@('Reader','Writer','Outsider');registration='real AD computer objects'} } 'Configure' { Assert-Mutation; Sync-Ownership; Assert-OwnedMembers if (-not $script:state.deployed_phases.ContainsKey('Identity')) { throw 'Deploy the reviewed identity plan before Configure.' } if ($script:state.active_fault) {throw 'Repair the active fault before Configure.'} Start-Operation 'configure' $script:state.configure_client_report=Invoke-Guest 'client' 'ConfigureClient';Save-Run foreach ($role in @('control','exercise')) { $account=$script:state.outputs.storage_accounts[$role] Set-OwnedStorageIdentity $role Invoke-Az @('storage','account','file-service-properties','update','--resource-group',(Output-Value 'resource_group_name'),'--account-name',$account.name,'--versions','SMB3.1.1','--auth-methods','Kerberos','--kerb-ticket-encryption','AES-256','--channel-encryption','AES-256-GCM','--require-smb-encryption-in-transit','true') | Out-Null } $script:state.configure_report=Invoke-Guest 'client' 'ConfigureShares' @{} $PropagationWaitSeconds $script:state.configured=$true;Sync-Ownership;Complete-Operation;Add-Check 'configure' $true @{authentication='Kerberos';ticket_encryption='AES256';protocol='SMB3.1.1';channel='AES256GCM'} } 'Fault' { Assert-Mutation;Assert-OwnedMembers if (-not $script:state.configured) {throw 'Configure and validate the healthy lab before introducing faults.'} if ($script:state.active_fault -and -not ($Resume -and $script:state.active_fault -eq $Scenario)) {throw 'Only one owned fault may be active.'} if (-not $script:state.active_fault) { if (-not $script:state.baseline_passes.Count -or [DateTimeOffset]::Parse($script:state.baseline_passes[-1]) -lt [DateTimeOffset]::UtcNow.AddMinutes(-15)) {throw 'A fresh successful healthy Validate is required before each fault.'} } $script:state.active_fault=$Scenario;Save-Run;Start-Operation ('fault-'+$Scenario) switch ($Scenario) { 'Kerberos' {$null=Invoke-Guest 'dc' 'FaultKerberos'} 'ShareGate' {Set-ShareGate 'StorageFileDataSmbShareReader'} 'Ntfs' {$null=Invoke-Guest 'client' 'FaultNtfs'} } if(-not $script:state.fault_counts.ContainsKey($Scenario)){$script:state.fault_counts[$Scenario]=0} $script:state.fault_counts[$Scenario]++;Complete-Operation Write-Host "Fault $Scenario applied to the exact exercise target. Validate the intended failure before repairing." } 'Repair' { Assert-Mutation;Assert-OwnedMembers if ($script:state.active_fault -and $script:state.active_fault -ne $Scenario) {throw 'The active fault belongs to a different exercise.'} if (-not $script:state.fault_counts.ContainsKey($Scenario)) {throw 'No owned fault exists to repair.'} Start-Operation ('repair-'+$Scenario) switch ($Scenario) { 'Kerberos' {$null=Invoke-Guest 'dc' 'RepairKerberos'} 'ShareGate' {Set-ShareGate 'StorageFileDataSmbShareElevatedContributor'} 'Ntfs' {$null=Invoke-Guest 'client' 'RepairNtfs'} } $script:state.active_fault='' if(-not $script:state.repair_counts.ContainsKey($Scenario)){$script:state.repair_counts[$Scenario]=0} $script:state.repair_counts[$Scenario]++;Complete-Operation Write-Host "Repair $Scenario completed. Fresh user-session validation is still required." } 'Validate' { Assert-Mutation if (-not $script:state.configured) {throw 'Configure the lab before running user acceptance.'} Start-Operation ('validate-'+$Scenario) $nonce=[guid]::NewGuid().ToString('N') $nonceHash=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($nonce))).ToLowerInvariant() $script:state.probe_requests+=@{nonceHash=$nonceHash;requested_utc=[DateTimeOffset]::UtcNow.ToString('o');fault=$script:state.active_fault};Save-Run $probe=Invoke-Guest 'client' 'Probe' @{actor='All';nonce=$nonce} 1200 $configuration=Get-ConfigurationAudit $dcAudit=Invoke-Guest 'dc' 'Audit' $script:state.spn_audit=$dcAudit.spnAudit;Save-Run $acceptance=Test-AzureFilesLiveAcceptance -Probe $probe -Configuration $configuration -State $script:state -Scenario $Scenario Add-Check $acceptance.name $acceptance.passed $acceptance.observed $acceptance.phase $script:state.probe_luids[$nonceHash]=@($probe.reports|ForEach-Object {$_.identity.luid});Save-Run if (-not $script:state.active_fault -and -not $Scenario) {$script:state.baseline_passes+=[DateTimeOffset]::UtcNow.ToString('o');$script:state.require_fresh_baseline_after_rejection=$false;Save-Run} if ($CollectLogs) { $script:state.windows_smb_audit=Invoke-Guest 'client' 'Audit';Save-Run $logs=Get-FileLogs $kerberosCorrelation=Get-RetainedKerberosCorrelation -State $script:state -PrivateDirectory $privateDirectory Add-Check 'kerberos-event-correlation' $kerberosCorrelation.passed $kerberosCorrelation.observed 'final' $logAudit=Test-AzureFilesLogCorrelation -Rows $logs -State $script:state Add-Check 'storage-log-correlation' $logAudit.passed $logAudit.observed 'final' Add-Check 'final-healthy' $acceptance.passed $acceptance.observed 'final' } Sync-Ownership;Complete-Operation } 'Cleanup' { if (-not (Invoke-Az @('group','exists','--name',($script:state.prefix+'-rg')))) { $baseline=Test-ProtectedBaseline if ($Execute) {$script:state.cleanup_count++;Save-Run} $name=if($script:state.cleanup_count -ge 2){'cleanup-repeat'}else{'cleanup-absence'} Add-Check $name $true @{group_exists=$false;owned_resources=0} 'cleanup' Add-Check 'baseline-preserved' $baseline.preserved @{missing=$baseline.missing;protected_count=$baseline.protected_count} 'cleanup' Write-Host 'Exact workshop group remains absent. No Azure resource was created.' break } Sync-Ownership;Assert-OwnedMembers if (-not (Test-Path -LiteralPath $tfstate)) {throw 'Owned Terraform state is required before deleting an existing workshop group.'} $destroy=Join-Path $privateDirectory 'cleanup.tfplan' if (-not $Execute) { $selected=if($script:state.deployed_phases.ContainsKey('Identity')){'Identity'}else{'Foundation'} Set-PhaseVariables $selected Invoke-Tf @('plan','-destroy','-input=false',"-state=$tfstate","-var-file=$tfvars","-out=$destroy") | Out-Null $destroyData=(Invoke-Tf @('show','-json',$destroy)).StdOut|ConvertFrom-Json -AsHashtable -Depth 100 foreach ($change in @($destroyData.resource_changes)) { if ($change.mode -ne 'managed' -or 'delete' -notin $change.change.actions) {continue} $id=[string]$change.change.before.id;if($change.type -eq 'azurerm_monitor_diagnostic_setting'){$id=$id.Split('|')[0]} if (-not (Test-AzureFilesOwnedId -Id $id -Scope (Get-Scope) -Baseline $script:state.baseline_resource_ids)) {throw 'Cleanup plan includes a protected or unrelated resource.'} } $script:state.cleanup_plan=@{path=$destroy;sha256=(Get-WorkshopFileHash $destroy);vars_sha256=(Get-WorkshopFileHash $tfvars)};Save-Run Write-Host ('Cleanup preview: exact owned group and {0} recorded resources. Review before -Execute -ReviewedPlan.' -f $script:state.owned_ids.Count) break } Assert-Mutation if (-not $ReviewedPlan -or -not $script:state.ContainsKey('cleanup_plan')) {throw 'Review Cleanup preview and supply -ReviewedPlan.'} $review=$script:state.cleanup_plan if ((Get-WorkshopFileHash $review.path) -ne $review.sha256 -or (Get-WorkshopFileHash $tfvars) -ne $review.vars_sha256) {throw 'Cleanup plan or variables changed after review.'} Start-Operation 'cleanup' Invoke-Tf @('apply','-input=false',"-state=$tfstate",$review.path) 2400 | Out-Null $deadline=[DateTimeOffset]::UtcNow.AddMinutes(15) while (Invoke-Az @('group','exists','--name',($script:state.prefix+'-rg'))) { if ([DateTimeOffset]::UtcNow -ge $deadline) {throw 'Deletion has not yet reached confirmed absence. Retain exact private state.'} Start-Sleep -Seconds 15 } $script:state.cleanup_count++;$script:state.active_fault='';Complete-Operation $baseline=Test-ProtectedBaseline Add-Check 'cleanup-absence' $true @{group_exists=$false;owned_resources=0} 'cleanup' Add-Check 'baseline-preserved' $baseline.preserved @{missing=$baseline.missing;protected_count=$baseline.protected_count} 'cleanup' } 'Export' {Export-SanitizedEvidence} } } catch { if ($script:state) { $script:state.last_failure=@{stage=$Stage;phase=$Phase;scenario=$Scenario;utc=[DateTimeOffset]::UtcNow.ToString('o');message=$_.Exception.Message}; Save-Run } throw } finally { foreach ($name in $savedEnv.Keys) { [Environment]::SetEnvironmentVariable($name,$savedEnv[$name],'Process') } $runLock.Dispose() }