[CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]{7,63}$')] [string]$CampaignId, [Parameter(Mandatory = $true)][string]$UatAuthorizationFile, [Parameter(Mandatory = $true)][string]$VerifierCliPath, [Parameter(Mandatory = $true)][string]$RuntimeCliPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedUatAuthorizationSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedVerifierCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')] [string]$ExpectedRuntimeCliVersion, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedRuntimeCliSha256, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedVerifierSignerThumbprint, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedRuntimeSignerThumbprint, [string]$CaseCatalogFile = (Join-Path $PSScriptRoot ` 'workflow-write-uat-case-catalog.v1.json'), [Parameter(Mandatory = $true)][string]$OutputRoot, [ValidateRange(1000, 60000)][int]$CliTimeoutMilliseconds = 30000 ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or [string]$PSVersionTable.PSEdition -ne 'Desktop' -or [string]::IsNullOrWhiteSpace($env:SystemRoot)) { throw 'workflow_uat_campaign_failed:windows_powershell_51_required' } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'workflow_uat_campaign_failed:elevated_operator_required' } $utf8 = [Text.UTF8Encoding]::new($false, $true) $maximumResponseCharacters = 4 * 1024 * 1024 $locks = New-Object System.Collections.Generic.List[IO.FileStream] $campaignDirectory = $null $expectedCaseCatalogSha256 = ` '23eb6c4f308d4904bf3920ed37499f05521beebde9422026f9732983c16002d5' function Throw-CampaignError([string]$Code) { throw ('workflow_uat_campaign_failed:' + $Code) } function Test-ExactProperties([object]$Value, [string[]]$Expected) { if ($null -eq $Value) { return $false } $names = @($Value.PSObject.Properties | ForEach-Object { $_.Name }) if ($names.Count -ne $Expected.Count) { return $false } foreach ($name in $Expected) { if ($names -cnotcontains $name) { return $false } } return $true } function Test-ExactStringArray([object[]]$Actual, [string[]]$Expected) { $values = @($Actual) if ($values.Count -ne $Expected.Count) { return $false } for ($index = 0; $index -lt $Expected.Count; $index++) { if ([string]$values[$index] -cne $Expected[$index]) { return $false } } return $true } function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) { try { $current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory)) while ($null -ne $current) { if (-not $current.Exists -or (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Throw-CampaignError $Code } $current = $current.Parent } } catch { if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw } Throw-CampaignError $Code } } function Open-LockedRegularFile( [string]$Path, [long]$MaximumBytes, [string]$ExpectedFileName, [string]$Code ) { try { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { Throw-CampaignError $Code } $item = Get-Item -LiteralPath $full -Force if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or (-not [string]::IsNullOrWhiteSpace($ExpectedFileName) -and [IO.Path]::GetFileName($full) -cne $ExpectedFileName)) { Throw-CampaignError $Code } Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code $stream = [IO.File]::Open( $full, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $script:locks.Add($stream) return [pscustomobject]@{ Path = $full; Stream = $stream } } catch { if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw } Throw-CampaignError $Code } } function Get-Sha256Hex([byte[]]$Bytes) { $sha = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } function Get-LockedSha256([IO.FileStream]$Stream) { $sha = [Security.Cryptography.SHA256]::Create() try { $Stream.Position = 0 $value = ([BitConverter]::ToString($sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant() $Stream.Position = 0 return $value } finally { $sha.Dispose() } } function Read-LockedJson([object]$LockedFile, [string]$Code) { try { $LockedFile.Stream.Position = 0 $reader = New-Object IO.StreamReader( $LockedFile.Stream, $utf8, $true, 4096, $true) try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() } $LockedFile.Stream.Position = 0 return $text | ConvertFrom-Json } catch { if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw } Throw-CampaignError $Code } } function ConvertTo-UnixSeconds([object]$Value) { $timestamp = ([DateTimeOffset]$Value).ToUniversalTime() $epoch = [DateTimeOffset]::new( 1970, 1, 1, 0, 0, 0, [TimeSpan]::Zero) return [int64][Math]::Floor(($timestamp - $epoch).TotalSeconds) } function ConvertTo-WindowsProcessArgument([string]$Value) { if ($null -eq $Value -or $Value.Length -eq 0) { return '""' } if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) { return $Value } $builder = New-Object Text.StringBuilder [void]$builder.Append([char]34) $slashes = 0 foreach ($character in $Value.ToCharArray()) { if ([int]$character -eq 92) { $slashes++; continue } if ([int]$character -eq 34) { for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) { [void]$builder.Append([char]92) } [void]$builder.Append([char]34) } else { for ($index = 0; $index -lt $slashes; $index++) { [void]$builder.Append([char]92) } [void]$builder.Append($character) } $slashes = 0 } for ($index = 0; $index -lt ($slashes * 2); $index++) { [void]$builder.Append([char]92) } [void]$builder.Append([char]34) return $builder.ToString() } function Invoke-TrustedCli([string]$CliPath, [string[]]$Arguments) { $process = New-Object Diagnostics.Process try { $start = New-Object Diagnostics.ProcessStartInfo $start.FileName = $CliPath $start.WorkingDirectory = [IO.Path]::GetDirectoryName($CliPath) $start.UseShellExecute = $false $start.CreateNoWindow = $true $start.RedirectStandardOutput = $true $start.RedirectStandardError = $true $start.RedirectStandardInput = $true $start.StandardOutputEncoding = $utf8 $start.StandardErrorEncoding = $utf8 $start.Arguments = (($Arguments | ForEach-Object { ConvertTo-WindowsProcessArgument ([string]$_) }) -join ' ') $process.StartInfo = $start if (-not $process.Start()) { Throw-CampaignError 'cli_process_start_failed' } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.StandardInput.Close() if (-not $process.WaitForExit($CliTimeoutMilliseconds)) { try { $process.Kill() } catch { } Throw-CampaignError 'cli_timeout' } $process.WaitForExit() $stdout = $stdoutTask.Result $stderr = $stderrTask.Result if ($process.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($stdout) -or -not [string]::IsNullOrWhiteSpace($stderr) -or $stdout.Length -gt $maximumResponseCharacters) { Throw-CampaignError 'uat_authorization_verification_failed' } try { $envelope = $stdout | ConvertFrom-Json } catch { Throw-CampaignError 'cli_response_invalid' } if (-not (Test-ExactProperties $envelope @('ok', 'correlationId', 'data')) -or $envelope.ok -ne $true -or ([string]$envelope.correlationId) -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') { Throw-CampaignError 'cli_response_invalid' } return $envelope.data } finally { $process.Dispose() } } function Assert-RestrictedDirectoryAcl([string]$Path) { try { $sections = [Security.AccessControl.AccessControlSections]::All $acl = [IO.Directory]::GetAccessControl($Path, $sections) $owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value $currentSid = $identity.User.Value $systemSid = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null).Value $rules = @($acl.GetAccessRules( $true, $false, [Security.Principal.SecurityIdentifier])) $seen = @{} foreach ($rule in $rules) { $sid = $rule.IdentityReference.Value if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or $sid -cnotin @($currentSid, $systemSid) -or (($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl) -or $seen.ContainsKey($sid)) { Throw-CampaignError 'campaign_directory_acl_invalid' } $seen[$sid] = $true } $sddl = $acl.GetSecurityDescriptorSddlForm($sections) if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid -or $rules.Count -ne 2 -or -not $seen.ContainsKey($currentSid) -or -not $seen.ContainsKey($systemSid) -or $sddl -cnotmatch 'S:.*\(ML;(?=[A-Z]*OI)(?=[A-Z]*CI)[A-Z]*;NW;;;HI\)') { Throw-CampaignError 'campaign_directory_acl_invalid' } } catch { if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw } Throw-CampaignError 'campaign_directory_acl_invalid' } } function New-RestrictedCampaignDirectory([string]$Root, [string]$Name) { $fullRoot = [IO.Path]::GetFullPath($Root) if (-not [IO.Directory]::Exists($fullRoot)) { Throw-CampaignError 'output_root_invalid' } Assert-NoReparseDirectoryChain $fullRoot 'output_root_invalid' $target = Join-Path $fullRoot $Name if ([IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) { Throw-CampaignError 'campaign_directory_exists' } [IO.Directory]::CreateDirectory($target) | Out-Null $currentUser = $identity.User $localSystem = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $security = New-Object Security.AccessControl.DirectorySecurity $security.SetOwner($currentUser) $security.SetAccessRuleProtection($true, $false) $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit foreach ($principalSid in @($currentUser, $localSystem)) { $rule = [Security.AccessControl.FileSystemAccessRule]::new( $principalSid, [Security.AccessControl.FileSystemRights]::FullControl, $inheritance, [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow) $security.AddAccessRule($rule) } [IO.Directory]::SetAccessControl($target, $security) & "$env:SystemRoot\System32\icacls.exe" ` $target '/setintegritylevel' '(OI)(CI)H' | Out-Null if ($LASTEXITCODE -ne 0) { Throw-CampaignError 'campaign_directory_acl_invalid' } Assert-RestrictedDirectoryAcl $target return $target } function Write-NewJson([string]$Path, [object]$Value, [int]$Depth) { $bytes = $utf8.GetBytes(($Value | ConvertTo-Json -Depth $Depth) + [Environment]::NewLine) $stream = [IO.File]::Open( $Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() } finally { $stream.Dispose() } } $purchaseCases = @( 'purchase_unique_match_commit', 'purchase_ambiguous_match_blocked', 'purchase_overallocation_blocked', 'purchase_permission_denied', 'purchase_database_permission_recheck_denied', 'purchase_currency_field_missing_blocked', 'purchase_currency_crosswalk_unapproved_blocked', 'purchase_row_scope_denied', 'purchase_runtime_recheck_blocked', 'purchase_transaction_rollback', 'purchase_idempotency_replay', 'purchase_idempotency_conflict', 'purchase_audit_correlated' ) $leaveCases = @( 'leave_natural_language_resolution', 'leave_multi_day_calendar_resolution', 'leave_resolution_proof_bypass_blocked', 'leave_ambiguous_type_blocked', 'leave_ambiguous_flow_type_blocked', 'leave_time_segment_required_blocked', 'leave_local_time_zone_rejected', 'leave_other_employee_denied', 'leave_permission_denied', 'leave_database_permission_recheck_denied', 'leave_create_draft_commit', 'leave_submit_separate_confirmation', 'leave_overlap_blocked', 'leave_stale_flow_type_blocked', 'leave_runtime_recheck_blocked', 'leave_transaction_rollback', 'leave_idempotency_replay', 'leave_idempotency_conflict', 'leave_audit_correlated' ) $executeCases = @( 'purchase_unique_match_commit', 'purchase_database_permission_recheck_denied', 'purchase_currency_field_missing_blocked', 'purchase_currency_crosswalk_unapproved_blocked', 'purchase_row_scope_denied', 'purchase_runtime_recheck_blocked', 'purchase_transaction_rollback', 'purchase_idempotency_replay', 'purchase_idempotency_conflict', 'leave_database_permission_recheck_denied', 'leave_create_draft_commit', 'leave_submit_separate_confirmation', 'leave_stale_flow_type_blocked', 'leave_runtime_recheck_blocked', 'leave_transaction_rollback', 'leave_idempotency_replay', 'leave_idempotency_conflict' ) $derivedCases = @('purchase_audit_correlated', 'leave_audit_correlated') $postPlanStagingCases = @( 'purchase_runtime_recheck_blocked', 'leave_stale_flow_type_blocked', 'leave_runtime_recheck_blocked' ) function Get-ExpectedCommand([string]$CaseCode) { if ($CaseCode.StartsWith('purchase_', [StringComparison]::Ordinal)) { return 'purchase.invoice.create' } if ($CaseCode -in @( 'leave_natural_language_resolution', 'leave_multi_day_calendar_resolution', 'leave_ambiguous_type_blocked', 'leave_ambiguous_flow_type_blocked', 'leave_time_segment_required_blocked', 'leave_other_employee_denied')) { return 'hr.leave.resolve' } if ($CaseCode -eq 'leave_submit_separate_confirmation') { return 'hr.leave.submit' } return 'hr.leave.create' } function Get-AllowedCommands([string]$CommandName) { if ($CommandName -eq 'purchase.invoice.create') { return @('purchase.invoice.resolve', 'purchase.invoice.create') } if ($CommandName -eq 'hr.leave.create') { return @('hr.leave.resolve', 'hr.leave.create') } return @($CommandName) } function Get-Dependency([string]$CaseCode) { if ($CaseCode -in @( 'purchase_idempotency_replay', 'purchase_idempotency_conflict', 'purchase_audit_correlated')) { return 'purchase_unique_match_commit' } if ($CaseCode -in @( 'leave_submit_separate_confirmation', 'leave_idempotency_replay', 'leave_idempotency_conflict', 'leave_audit_correlated')) { return 'leave_create_draft_commit' } return $null } function Get-IdempotencyPolicy([string]$CaseCode, [string]$CaptureMode) { if ($CaptureMode -eq 'plan_only') { return 'not_applicable' } if ($CaptureMode -eq 'derived_audit') { return 'derived_no_execute' } if ($CaseCode.EndsWith('_idempotency_replay', [StringComparison]::Ordinal)) { return 'reuse_dependency_key_and_input' } if ($CaseCode.EndsWith('_idempotency_conflict', [StringComparison]::Ordinal)) { return 'reuse_dependency_key_with_different_input' } return 'new_unique_key' } function Test-SafeCatalogText([object]$Value, [int]$MaximumLength) { if ($null -eq $Value) { return $false } $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text) -or $text.Length -gt $MaximumLength -or $text -cne $text.Trim() -or [Text.RegularExpressions.Regex]::IsMatch($text, '[\x00-\x1f\x7f]') -or [Text.RegularExpressions.Regex]::IsMatch( $text, '(?i)(?:https?|jdbc|file)://|\b(?:password|passwd|secret|api[_ -]?key|token)\b|\b(?:\d{1,3}\.){3}\d{1,3}\b|sk-[A-Za-z0-9_-]{8,}|\b(?:insert\s+into|update\s+\S+\s+set|delete\s+from|drop\s+table|truncate\s+table|alter\s+table)\b')) { return $false } return $true } function Test-SafeCatalogTextArray( [object]$Value, [int]$MinimumCount, [int]$MaximumCount ) { if ($null -eq $Value -or -not ($Value -is [Array])) { return $false } $items = @($Value) if ($items.Count -lt $MinimumCount -or $items.Count -gt $MaximumCount) { return $false } foreach ($item in $items) { if (-not (Test-SafeCatalogText $item 500)) { return $false } } return $true } function Assert-CaseCatalog([object]$Catalog) { if (-not (Test-ExactProperties $Catalog @( 'schemaVersion', 'packageType', 'safety', 'workflows')) -or [string]$Catalog.schemaVersion -cne '1.0' -or [string]$Catalog.packageType -cne 'workflow_write_uat_case_catalog' -or -not (Test-ExactProperties $Catalog.safety @( 'productionUseProhibited', 'automaticDatabaseOrConfigurationChanges', 'approvedRestorePointRequired', 'containsCredentials', 'containsBusinessIdentifiers', 'executableInstructionsIncluded')) -or $Catalog.safety.productionUseProhibited -ne $true -or $Catalog.safety.automaticDatabaseOrConfigurationChanges -ne $false -or $Catalog.safety.approvedRestorePointRequired -ne $true -or $Catalog.safety.containsCredentials -ne $false -or $Catalog.safety.containsBusinessIdentifiers -ne $false -or $Catalog.safety.executableInstructionsIncluded -ne $false) { Throw-CampaignError 'case_catalog_safety_invalid' } $workflows = @($Catalog.workflows) if ($workflows.Count -ne 2) { Throw-CampaignError 'case_catalog_workflow_coverage_invalid' } $lookup = @{} for ($workflowIndex = 0; $workflowIndex -lt 2; $workflowIndex++) { $workflowName = if ($workflowIndex -eq 0) { 'purchase' } else { 'leave' } $expectedCases = if ($workflowName -eq 'purchase') { $purchaseCases } else { $leaveCases } $workflow = $workflows[$workflowIndex] if (-not (Test-ExactProperties $workflow @( 'workflow', 'caseCount', 'cases')) -or [string]$workflow.workflow -cne $workflowName -or [int]$workflow.caseCount -ne $expectedCases.Count) { Throw-CampaignError 'case_catalog_workflow_contract_invalid' } $cases = @($workflow.cases) if ($cases.Count -ne $expectedCases.Count) { Throw-CampaignError 'case_catalog_case_coverage_invalid' } for ($index = 0; $index -lt $expectedCases.Count; $index++) { $caseCode = $expectedCases[$index] $case = $cases[$index] $expectedCommand = Get-ExpectedCommand $caseCode $expectedCaptureMode = if ($caseCode -in $derivedCases) { 'derived_audit' } elseif ($caseCode -in $executeCases) { 'execute' } else { 'plan_only' } $expectedMutation = if ($caseCode -in @( 'purchase_unique_match_commit', 'leave_create_draft_commit', 'leave_submit_separate_confirmation')) { 'positive' } else { 'zero' } $expectedConfirmation = if ($expectedCaptureMode -eq 'derived_audit') { 'inherited_required' } elseif ($expectedCaptureMode -eq 'execute') { 'required' } else { 'prohibited' } $expectedSourceProof = $caseCode -in @( 'purchase_unique_match_commit', 'purchase_idempotency_replay', 'purchase_audit_correlated') if (-not (Test-ExactProperties $case @( 'sequence', 'caseCode', 'title', 'commandName', 'captureMode', 'expectedResultCode', 'expectedIssueCode', 'expectedMutationPolicy', 'nativeConfirmationPolicy', 'minimumAuditEventCount', 'sourceDocumentProofRequired', 'primaryRole', 'supportingRoles', 'fixtureCode', 'preconditions', 'operatorSteps', 'dbaReadOnlyChecks', 'cleanupSteps', 'retryPolicy')) -or [int]$case.sequence -ne ($index + 1) -or [string]$case.caseCode -cne $caseCode -or [string]$case.commandName -cne $expectedCommand -or [string]$case.captureMode -cne $expectedCaptureMode -or ([string]$case.expectedResultCode) -cnotmatch '^[a-z][a-z0-9_]{2,95}$' -or ($null -ne $case.expectedIssueCode -and ([string]$case.expectedIssueCode) -cnotmatch '^[a-z][a-z0-9_]{2,95}$') -or [string]$case.expectedMutationPolicy -cne $expectedMutation -or [string]$case.nativeConfirmationPolicy -cne $expectedConfirmation -or [int]$case.minimumAuditEventCount -ne $(if ($expectedCaptureMode -eq 'plan_only') { 1 } else { 2 }) -or [bool]$case.sourceDocumentProofRequired -ne $expectedSourceProof -or -not (Test-SafeCatalogText $case.title 120) -or ([string]$case.primaryRole) -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or ([string]$case.fixtureCode) -cnotmatch '^[a-z][a-z0-9_]{2,95}$' -or ([string]$case.retryPolicy) -cnotin @( 'single_success_then_relationship_cases_only', 'new_capture_allowed_while_authorization_active', 'new_plan_and_new_key_required', 'dependency_key_relationship_required', 'derived_with_dependency_only') -or -not (Test-SafeCatalogTextArray $case.preconditions 1 12) -or -not (Test-SafeCatalogTextArray $case.operatorSteps 1 12) -or -not (Test-SafeCatalogTextArray $case.dbaReadOnlyChecks 1 12) -or -not (Test-SafeCatalogTextArray $case.cleanupSteps 1 12) -or $lookup.ContainsKey($caseCode)) { Throw-CampaignError 'case_catalog_case_contract_invalid' } if (-not ($case.supportingRoles -is [Array])) { Throw-CampaignError 'case_catalog_case_contract_invalid' } $roles = @($case.supportingRoles) if ($roles.Count -lt 1 -or $roles.Count -gt 8 -or @($roles | Select-Object -Unique).Count -ne $roles.Count) { Throw-CampaignError 'case_catalog_case_contract_invalid' } foreach ($role in $roles) { if ([string]$role -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or [string]$role -ceq [string]$case.primaryRole) { Throw-CampaignError 'case_catalog_case_contract_invalid' } } $lookup[$caseCode] = $case } } if ($lookup.Count -ne 32) { Throw-CampaignError 'case_catalog_case_coverage_invalid' } return $lookup } try { $caseCatalog = Open-LockedRegularFile ` $CaseCatalogFile (256KB) 'workflow-write-uat-case-catalog.v1.json' ` 'case_catalog_file_invalid' $authorization = Open-LockedRegularFile ` $UatAuthorizationFile (512KB) '' 'uat_authorization_file_invalid' $verifierCli = Open-LockedRegularFile ` $VerifierCliPath (128MB) 'lserp-cli.exe' 'verifier_cli_invalid' $runtimeCli = Open-LockedRegularFile ` $RuntimeCliPath (128MB) 'lserp-agent-cli.exe' 'runtime_cli_invalid' if ($verifierCli.Path -ieq $runtimeCli.Path) { Throw-CampaignError 'cli_role_path_conflict' } $caseCatalogHash = Get-LockedSha256 $caseCatalog.Stream if ($caseCatalogHash -cne $expectedCaseCatalogSha256) { Throw-CampaignError 'case_catalog_hash_mismatch' } $caseCatalogJson = Read-LockedJson $caseCatalog 'case_catalog_json_invalid' $catalogCaseByCode = Assert-CaseCatalog $caseCatalogJson $authorizationSourceHash = Get-LockedSha256 $authorization.Stream if ($authorizationSourceHash -cne $ExpectedUatAuthorizationSha256.ToLowerInvariant()) { Throw-CampaignError 'uat_authorization_hash_mismatch' } $verifierCliHash = Get-LockedSha256 $verifierCli.Stream $runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream if ($verifierCliHash -cne $ExpectedVerifierCliSha256.ToLowerInvariant()) { Throw-CampaignError 'verifier_cli_hash_mismatch' } if ($runtimeCliHash -cne $ExpectedRuntimeCliSha256.ToLowerInvariant()) { Throw-CampaignError 'runtime_cli_hash_mismatch' } $verifierSignature = Get-AuthenticodeSignature -LiteralPath $verifierCli.Path $verifierSigner = if ($null -eq $verifierSignature.SignerCertificate) { '' } else { ([string]$verifierSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant() } if ($verifierSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $verifierSigner -cne $ExpectedVerifierSignerThumbprint.ToUpperInvariant()) { Throw-CampaignError 'verifier_cli_signature_invalid' } $runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCli.Path $runtimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) { '' } else { ([string]$runtimeSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant() } if ($runtimeSignature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $runtimeSigner -cne $ExpectedRuntimeSignerThumbprint.ToUpperInvariant()) { Throw-CampaignError 'runtime_cli_signature_invalid' } $runtimeIdentity = Invoke-TrustedCli $runtimeCli.Path @( 'version', '--correlation-id', ('campaign-runtime-' + [Guid]::NewGuid().ToString('N')) ) if (-not (Test-ExactProperties $runtimeIdentity @( 'component', 'version', 'protocolVersion', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or [string]$runtimeIdentity.component -cne 'lserp-agent-cli' -or [string]$runtimeIdentity.version -cne $ExpectedRuntimeCliVersion -or [string]$runtimeIdentity.protocolVersion -cne '1.0' -or $runtimeIdentity.bridgeOnly -ne $true -or $runtimeIdentity.databaseDirectAccess -ne $false -or [string]$runtimeIdentity.sessionSource -cne 'current_logged_in_erp_process') { Throw-CampaignError 'runtime_cli_identity_invalid' } $verified = Invoke-TrustedCli $verifierCli.Path @( 'acceptance', 'verify-uat-authorization', '--input', $authorization.Path, '--correlation-id', ('campaign-auth-' + [Guid]::NewGuid().ToString('N')) ) $verifiedProperties = @( 'packageType', 'schemaVersion', 'sourceSha256', 'contentSha256', 'authorizationId', 'authorizationIdSha256', 'customerId', 'environmentId', 'environmentClass', 'erpScope', 'runtimeConfigurationSha256', 'customerProfileSha256', 'rolloutPolicySha256', 'sourceCommit', 'packageSha256', 'erpExecutable', 'runtimeCli', 'verifierCli', 'workflows', 'issuedAtUtc', 'expiresAtUtc', 'approvedBy', 'signatureVerified', 'uatAuthorized', 'productionReady', 'note' ) if (-not (Test-ExactProperties $verified $verifiedProperties) -or $verified.packageType -cne 'workflow_write_uat_authorization' -or $verified.schemaVersion -cne '1.2' -or [string]$verified.sourceSha256 -cne $authorizationSourceHash -or -not (Test-ExactProperties $verified.runtimeCli @( 'fileName', 'version', 'sha256', 'signerThumbprint', 'requiresElevation', 'bridgeOnly', 'databaseDirectAccess', 'sessionSource')) -or [string]$verified.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or [string]$verified.runtimeCli.version -cne $ExpectedRuntimeCliVersion -or [string]$verified.runtimeCli.sha256 -cne $runtimeCliHash -or ([string]$verified.runtimeCli.signerThumbprint).ToUpperInvariant() -cne $runtimeSigner -or $verified.runtimeCli.requiresElevation -ne $false -or $verified.runtimeCli.bridgeOnly -ne $true -or $verified.runtimeCli.databaseDirectAccess -ne $false -or [string]$verified.runtimeCli.sessionSource -cne 'current_logged_in_erp_process' -or [string]$verified.verifierCli.sha256 -cne $verifierCliHash -or ([string]$verified.verifierCli.signerThumbprint).ToUpperInvariant() -cne $verifierSigner -or $verified.verifierCli.requiresElevation -ne $true -or $verified.environmentClass -cne 'recoverable_uat' -or $verified.signatureVerified -ne $true -or $verified.uatAuthorized -ne $true -or $verified.productionReady -ne $false) { Throw-CampaignError 'uat_authorization_contract_mismatch' } $authorization.Stream.Position = 0 $reader = New-Object IO.StreamReader($authorization.Stream, $utf8, $true, 4096, $true) try { $authorizationText = $reader.ReadToEnd() } finally { $reader.Dispose() } try { $authorizationJson = $authorizationText | ConvertFrom-Json } catch { Throw-CampaignError 'uat_authorization_json_invalid' } if (-not (Test-ExactProperties $authorizationJson @( 'schemaVersion', 'contentSha256', 'signatureAlgorithm', 'certificateThumbprint', 'signatureBase64', 'content')) -or [string]$authorizationJson.schemaVersion -cne '1.2' -or [string]$authorizationJson.contentSha256 -cne [string]$verified.contentSha256) { Throw-CampaignError 'uat_authorization_json_invalid' } $content = $authorizationJson.content if (-not (Test-ExactProperties $content @( 'packageType', 'authorizationId', 'customerId', 'environmentId', 'environmentClass', 'erpScope', 'runtimeConfigurationSha256', 'customerProfileSha256', 'rolloutPolicySha256', 'sourceCommit', 'packageSha256', 'erpExecutable', 'runtimeCli', 'verifierCli', 'safety', 'workflows', 'issuedAtUtc', 'expiresAtUtc', 'approvedBy', 'note')) -or -not (Test-ExactProperties $content.erpScope @( 'accountBook', 'subSystemId', 'userId', 'userName', 'databaseScopeFingerprint')) -or [string]$content.erpScope.databaseScopeFingerprint -cne [string]$verified.erpScope.databaseScopeFingerprint -or -not (Test-ExactProperties $content.safety @( 'databaseBackupVerified', 'restoreProcedureVerified', 'nonProductionEnvironmentVerified', 'productionUseProhibited', 'nativeConfirmationRequired', 'transactionAndAuditRequired', 'maximumPlanAttemptsPerCase', 'maximumExecuteAttemptsPerCase')) -or $content.safety.databaseBackupVerified -ne $true -or $content.safety.restoreProcedureVerified -ne $true -or $content.safety.nonProductionEnvironmentVerified -ne $true -or $content.safety.productionUseProhibited -ne $true -or $content.safety.nativeConfirmationRequired -ne $true -or $content.safety.transactionAndAuditRequired -ne $true -or [int]$content.safety.maximumPlanAttemptsPerCase -ne 6 -or [int]$content.safety.maximumExecuteAttemptsPerCase -ne 3) { Throw-CampaignError 'uat_authorization_safety_invalid' } $workflowNames = @($verified.workflows | ForEach-Object { [string]$_.workflow }) if ($workflowNames.Count -lt 1 -or $workflowNames.Count -gt 2 -or @($workflowNames | Select-Object -Unique).Count -ne $workflowNames.Count -or @($workflowNames | Where-Object { $_ -cnotin @('purchase', 'leave') }).Count -ne 0) { Throw-CampaignError 'uat_authorization_workflows_invalid' } $seenTokenHashes = @{} $campaignWorkflows = New-Object System.Collections.Generic.List[object] foreach ($workflowName in @('purchase', 'leave')) { if ($workflowNames -cnotcontains $workflowName) { continue } $expectedCases = if ($workflowName -eq 'purchase') { $purchaseCases } else { $leaveCases } $sourceWorkflow = @($content.workflows | Where-Object { [string]$_.workflow -ceq $workflowName }) $verifiedWorkflow = @($verified.workflows | Where-Object { [string]$_.workflow -ceq $workflowName }) if ($sourceWorkflow.Count -ne 1 -or $verifiedWorkflow.Count -ne 1 -or -not (Test-ExactProperties $sourceWorkflow[0] @( 'workflow', 'moduleCode', 'adapterId', 'adapterVersion', 'cases')) -or [int]$verifiedWorkflow[0].caseCount -ne $expectedCases.Count -or [string]$sourceWorkflow[0].moduleCode -cne [string]$verifiedWorkflow[0].moduleCode) { Throw-CampaignError 'uat_authorization_workflow_contract_invalid' } $sourceCases = @($sourceWorkflow[0].cases) if ($sourceCases.Count -ne $expectedCases.Count) { Throw-CampaignError 'uat_authorization_case_coverage_invalid' } $campaignCases = New-Object System.Collections.Generic.List[object] for ($index = 0; $index -lt $expectedCases.Count; $index++) { $caseCode = $expectedCases[$index] $sourceCase = $sourceCases[$index] $commandName = Get-ExpectedCommand $caseCode $catalogCase = $catalogCaseByCode[$caseCode] $allowedCommands = @(Get-AllowedCommands $commandName) if (-not (Test-ExactProperties $sourceCase @( 'caseCode', 'expectedCommandName', 'allowedCommands', 'tokenSha256')) -or [string]$sourceCase.caseCode -cne $caseCode -or [string]$sourceCase.expectedCommandName -cne $commandName -or [string]$catalogCase.commandName -cne $commandName -or -not (Test-ExactStringArray @($sourceCase.allowedCommands) $allowedCommands) -or ([string]$sourceCase.tokenSha256) -cnotmatch '^[a-f0-9]{64}$' -or $seenTokenHashes.ContainsKey([string]$sourceCase.tokenSha256)) { Throw-CampaignError 'uat_authorization_case_contract_invalid' } $seenTokenHashes[[string]$sourceCase.tokenSha256] = $true $captureMode = if ($caseCode -in $derivedCases) { 'derived_audit' } elseif ($caseCode -in $executeCases) { 'execute' } else { 'plan_only' } if ([string]$catalogCase.captureMode -cne $captureMode) { Throw-CampaignError 'case_catalog_case_contract_invalid' } $dependency = Get-Dependency $caseCode $auditOutput = if ($caseCode -eq 'purchase_unique_match_commit') { 'purchase_audit_correlated.json' } elseif ($caseCode -eq 'leave_create_draft_commit') { 'leave_audit_correlated.json' } else { $null } $operatorStage = if ($captureMode -eq 'derived_audit') { 'derived_from_dependency' } elseif ($caseCode -in $postPlanStagingCases) { 'post_plan_change_required' } elseif ($caseCode.EndsWith('_transaction_rollback', [StringComparison]::Ordinal)) { 'controlled_failure_fixture_required' } elseif ($null -ne $dependency) { 'relationship_fixture_required' } else { 'case_fixture_required' } $campaignCases.Add([pscustomobject][ordered]@{ sequence = $index + 1 caseCode = $caseCode commandName = $commandName captureMode = $captureMode execute = ($captureMode -eq 'execute') pauseAfterPlanForOperatorStaging = ($caseCode -in $postPlanStagingCases) operatorStage = $operatorStage dependencyCaseCode = $dependency idempotencyPolicy = Get-IdempotencyPolicy $caseCode $captureMode inputFile = if ($captureMode -eq 'derived_audit') { $null } else { 'private-input/' + $caseCode + '.json' } evidenceFile = $caseCode + '.json' correlatedAuditEvidenceFile = $auditOutput requiresDbaReadOnlyObservation = $true expectedBusinessMutation = [string]$catalogCase.expectedMutationPolicy }) } $campaignWorkflows.Add([pscustomobject][ordered]@{ workflow = $workflowName moduleCode = [string]$sourceWorkflow[0].moduleCode caseCount = $expectedCases.Count caseIndexFile = 'evidence/' + $workflowName + '-index.json' assembledCasesFile = 'evidence/' + $workflowName + '-cases.json' cases = @($campaignCases) }) } if ($campaignWorkflows.Count -ne $workflowNames.Count) { Throw-CampaignError 'uat_authorization_workflows_invalid' } $campaignDirectory = New-RestrictedCampaignDirectory $OutputRoot $CampaignId $privateInputDirectory = Join-Path $campaignDirectory 'private-input' $evidenceDirectory = Join-Path $campaignDirectory 'evidence' [IO.Directory]::CreateDirectory($privateInputDirectory) | Out-Null [IO.Directory]::CreateDirectory($evidenceDirectory) | Out-Null Assert-RestrictedDirectoryAcl $campaignDirectory $createdAtUnixSeconds = ConvertTo-UnixSeconds ([DateTimeOffset]::UtcNow) $manifestContent = [pscustomobject][ordered]@{ packageType = 'workflow_write_uat_campaign' campaignId = $CampaignId createdAtUnixSeconds = $createdAtUnixSeconds caseCatalogSha256 = $caseCatalogHash authorization = [pscustomobject][ordered]@{ sourceSha256 = [string]$verified.sourceSha256 contentSha256 = [string]$verified.contentSha256 authorizationIdSha256 = [string]$verified.authorizationIdSha256 customerId = [string]$verified.customerId environmentId = [string]$verified.environmentId environmentClass = 'recoverable_uat' issuedAtUnixSeconds = ConvertTo-UnixSeconds $verified.issuedAtUtc expiresAtUnixSeconds = ConvertTo-UnixSeconds $verified.expiresAtUtc verifierCliSha256 = $verifierCliHash verifierCliSignerThumbprint = $verifierSigner runtimeCliVersion = $ExpectedRuntimeCliVersion runtimeCliSha256 = $runtimeCliHash runtimeCliSignerThumbprint = $runtimeSigner userIdSha256 = [string]$verified.erpScope.userIdSha256 userNameSha256 = [string]$verified.erpScope.userNameSha256 databaseScopeFingerprint = [string]$verified.erpScope.databaseScopeFingerprint } safety = [pscustomobject][ordered]@{ productionUseProhibited = $true automaticDatabaseWrites = $false oneCaseAtATime = $true operatorStagingRequired = $true authorizationReverificationRequiredBeforeResume = $true tokenMaterialIncluded = $false idempotencyMaterialIncluded = $false privateBusinessInputIncluded = $false resumeStateDerivedOnlyFromEvidence = $true } directories = [pscustomobject][ordered]@{ privateInput = 'private-input' evidence = 'evidence' } workflows = @($campaignWorkflows) productionReady = $false } $manifestContentJson = $manifestContent | ConvertTo-Json -Depth 14 -Compress $manifestContentSha256 = Get-Sha256Hex $utf8.GetBytes($manifestContentJson) $manifest = [pscustomobject][ordered]@{ schemaVersion = '1.1' contentSha256 = $manifestContentSha256 content = $manifestContent } Write-NewJson (Join-Path $campaignDirectory 'campaign.json') $manifest 16 foreach ($workflow in $campaignWorkflows) { $indexDocument = [pscustomobject][ordered]@{ schemaVersion = '1.0' workflow = [string]$workflow.workflow caseFiles = @($workflow.cases | ForEach-Object { [string]$_.evidenceFile }) } Write-NewJson ` (Join-Path $evidenceDirectory ([string]$workflow.workflow + '-index.json')) ` $indexDocument 5 } $campaignCaseCount = 0 foreach ($workflow in $campaignWorkflows) { $campaignCaseCount += @($workflow.cases).Count } [pscustomobject][ordered]@{ campaignFile = Join-Path $campaignDirectory 'campaign.json' campaignId = $CampaignId campaignContentSha256 = $manifestContentSha256 caseCatalogSha256 = $caseCatalogHash workflowCount = $campaignWorkflows.Count caseCount = $campaignCaseCount authorizationSignatureVerified = $true verifierCliSha256 = $verifierCliHash runtimeCliVersion = $ExpectedRuntimeCliVersion runtimeCliSha256 = $runtimeCliHash runtimeCliSignerThumbprint = $runtimeSigner automaticDatabaseWrites = $false tokenMaterialIncluded = $false readyForOneCaseAtATimeCapture = $true productionReady = $false } | ConvertTo-Json -Depth 5 } catch { if ($null -ne $campaignDirectory -and [IO.Directory]::Exists($campaignDirectory)) { try { [IO.Directory]::Delete($campaignDirectory, $true) } catch { } } throw } finally { foreach ($lock in @($locks)) { if ($null -ne $lock) { $lock.Dispose() } } }