Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/Deployment/New-WorkflowWriteUatCampaign.ps1
2026-08-14 14:28:28 +08:00

932 lines
43 KiB
PowerShell

[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() }
}
}