1204 lines
58 KiB
PowerShell
1204 lines
58 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$CampaignFile,
|
|
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
|
|
[Parameter(Mandatory = $true)][string]$UatTokenVaultPath,
|
|
[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)]
|
|
[ValidateRange(1, 2147483647)]
|
|
[int]$ErpProcessId,
|
|
[ValidateRange(1000, 300000)]
|
|
[int]$BridgeTimeoutMilliseconds = 180000
|
|
)
|
|
|
|
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_check_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_check_failed:elevated_operator_required'
|
|
}
|
|
|
|
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
|
$maximumResponseCharacters = 4 * 1024 * 1024
|
|
$locks = New-Object System.Collections.Generic.List[IO.FileStream]
|
|
$expectedCaseCatalogSha256 = `
|
|
'23eb6c4f308d4904bf3920ed37499f05521beebde9422026f9732983c16002d5'
|
|
|
|
function Throw-CampaignCheckError([string]$Code) {
|
|
throw ('workflow_uat_campaign_check_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 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-CampaignCheckError $Code
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_check_failed:')) { throw }
|
|
Throw-CampaignCheckError $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-CampaignCheckError $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-CampaignCheckError $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_check_failed:')) { throw }
|
|
Throw-CampaignCheckError $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 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 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_check_failed:')) { throw }
|
|
Throw-CampaignCheckError $Code
|
|
}
|
|
}
|
|
|
|
function Assert-RestrictedAcl([string]$Path, [bool]$IsDirectory, [string]$Code) {
|
|
try {
|
|
$sections = [Security.AccessControl.AccessControlSections]::All
|
|
$acl = if ($IsDirectory) {
|
|
[IO.Directory]::GetAccessControl($Path, $sections)
|
|
} else { [IO.File]::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
|
|
if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid) {
|
|
Throw-CampaignCheckError $Code
|
|
}
|
|
$rules = @($acl.GetAccessRules(
|
|
$true, $true, [Security.Principal.SecurityIdentifier]))
|
|
if ($rules.Count -ne 2) { Throw-CampaignCheckError $Code }
|
|
$seen = @{}
|
|
foreach ($rule in $rules) {
|
|
$sid = [string]$rule.IdentityReference.Value
|
|
if ($rule.IsInherited -or
|
|
$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-CampaignCheckError $Code
|
|
}
|
|
$seen[$sid] = $true
|
|
}
|
|
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
|
|
$integrityPattern = if ($IsDirectory) {
|
|
'S:.*\(ML;(?=[A-Z]*OI)(?=[A-Z]*CI)[A-Z]*;NW;;;HI\)'
|
|
} else { 'S:.*\(ML;;NW;;;HI\)' }
|
|
if (-not $seen.ContainsKey($currentSid) -or
|
|
-not $seen.ContainsKey($systemSid) -or
|
|
$sddl -cnotmatch $integrityPattern) {
|
|
Throw-CampaignCheckError $Code
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_check_failed:')) { throw }
|
|
Throw-CampaignCheckError $Code
|
|
}
|
|
}
|
|
|
|
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, [int]$Timeout) {
|
|
$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-CampaignCheckError 'cli_process_start_failed' }
|
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
|
$process.StandardInput.Close()
|
|
if (-not $process.WaitForExit($Timeout)) {
|
|
try { $process.Kill() } catch { }
|
|
Throw-CampaignCheckError '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-CampaignCheckError 'cli_verification_failed'
|
|
}
|
|
try { $envelope = $stdout | ConvertFrom-Json }
|
|
catch { Throw-CampaignCheckError '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}$' -or
|
|
$null -eq $envelope.data) {
|
|
Throw-CampaignCheckError 'cli_response_invalid'
|
|
}
|
|
return $envelope.data
|
|
}
|
|
finally { $process.Dispose() }
|
|
}
|
|
|
|
function Assert-PathWithin([string]$Path, [string]$Root, [string]$Code) {
|
|
$fullPath = [IO.Path]::GetFullPath($Path)
|
|
$fullRoot = [IO.Path]::GetFullPath($Root).TrimEnd(
|
|
[IO.Path]::DirectorySeparatorChar,
|
|
[IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
if (-not $fullPath.StartsWith($fullRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
|
Throw-CampaignCheckError $Code
|
|
}
|
|
}
|
|
|
|
$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-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-CampaignCheckError 'case_catalog_safety_invalid'
|
|
}
|
|
|
|
$workflows = @($Catalog.workflows)
|
|
if ($workflows.Count -ne 2) {
|
|
Throw-CampaignCheckError '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-CampaignCheckError 'case_catalog_workflow_contract_invalid'
|
|
}
|
|
$cases = @($workflow.cases)
|
|
if ($cases.Count -ne $expectedCases.Count) {
|
|
Throw-CampaignCheckError '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-CampaignCheckError 'case_catalog_case_contract_invalid'
|
|
}
|
|
if (-not ($case.supportingRoles -is [Array])) {
|
|
Throw-CampaignCheckError '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-CampaignCheckError '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-CampaignCheckError 'case_catalog_case_contract_invalid'
|
|
}
|
|
}
|
|
$lookup[$caseCode] = $case
|
|
}
|
|
}
|
|
if ($lookup.Count -ne 32) {
|
|
Throw-CampaignCheckError 'case_catalog_case_coverage_invalid'
|
|
}
|
|
return $lookup
|
|
}
|
|
|
|
try {
|
|
$caseCatalog = Open-LockedRegularFile `
|
|
$CaseCatalogFile (256KB) 'workflow-write-uat-case-catalog.v1.json' `
|
|
'case_catalog_file_invalid'
|
|
$campaign = Open-LockedRegularFile `
|
|
$CampaignFile (2MB) 'campaign.json' 'campaign_file_invalid'
|
|
$authorization = Open-LockedRegularFile `
|
|
$UatAuthorizationFile (512KB) '' 'uat_authorization_file_invalid'
|
|
$vault = Open-LockedRegularFile `
|
|
$UatTokenVaultPath (512KB) '' 'uat_token_vault_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-CampaignCheckError 'cli_role_path_conflict'
|
|
}
|
|
$campaignRoot = [IO.Path]::GetDirectoryName($campaign.Path)
|
|
Assert-RestrictedAcl $campaignRoot $true 'campaign_directory_acl_invalid'
|
|
Assert-RestrictedAcl $vault.Path $false 'uat_token_vault_acl_invalid'
|
|
Assert-PathWithin $campaign.Path $campaignRoot 'campaign_path_invalid'
|
|
$campaignPrefix = $campaignRoot.TrimEnd(
|
|
[IO.Path]::DirectorySeparatorChar,
|
|
[IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
if ($vault.Path.StartsWith($campaignPrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
|
Throw-CampaignCheckError 'uat_token_vault_inside_campaign'
|
|
}
|
|
|
|
$caseCatalogHash = Get-LockedSha256 $caseCatalog.Stream
|
|
if ($caseCatalogHash -cne $expectedCaseCatalogSha256) {
|
|
Throw-CampaignCheckError 'case_catalog_hash_mismatch'
|
|
}
|
|
$caseCatalogJson = Read-LockedJson $caseCatalog 'case_catalog_json_invalid'
|
|
$catalogCaseByCode = Assert-CaseCatalog $caseCatalogJson
|
|
$authorizationHash = Get-LockedSha256 $authorization.Stream
|
|
$verifierCliHash = Get-LockedSha256 $verifierCli.Stream
|
|
$runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream
|
|
if ($authorizationHash -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant()) {
|
|
Throw-CampaignCheckError 'uat_authorization_hash_mismatch'
|
|
}
|
|
if ($verifierCliHash -cne
|
|
$ExpectedVerifierCliSha256.ToLowerInvariant()) {
|
|
Throw-CampaignCheckError 'verifier_cli_hash_mismatch'
|
|
}
|
|
if ($runtimeCliHash -cne $ExpectedRuntimeCliSha256.ToLowerInvariant()) {
|
|
Throw-CampaignCheckError '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-CampaignCheckError '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-CampaignCheckError 'runtime_cli_signature_invalid'
|
|
}
|
|
|
|
$runtimeIdentity = Invoke-TrustedCli $runtimeCli.Path @(
|
|
'version', '--correlation-id',
|
|
('campaign-runtime-' + [Guid]::NewGuid().ToString('N'))
|
|
) $BridgeTimeoutMilliseconds
|
|
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-CampaignCheckError 'runtime_cli_identity_invalid'
|
|
}
|
|
|
|
$verifiedAuthorization = Invoke-TrustedCli $verifierCli.Path @(
|
|
'acceptance', 'verify-uat-authorization', '--input', $authorization.Path,
|
|
'--correlation-id', ('campaign-auth-' + [Guid]::NewGuid().ToString('N'))
|
|
) $BridgeTimeoutMilliseconds
|
|
if ([string]$verifiedAuthorization.packageType -cne
|
|
'workflow_write_uat_authorization' -or
|
|
[string]$verifiedAuthorization.schemaVersion -cne '1.2' -or
|
|
[string]$verifiedAuthorization.sourceSha256 -cne $authorizationHash -or
|
|
[string]$verifiedAuthorization.runtimeCli.fileName -cne
|
|
'lserp-agent-cli.exe' -or
|
|
[string]$verifiedAuthorization.runtimeCli.version -cne
|
|
$ExpectedRuntimeCliVersion -or
|
|
[string]$verifiedAuthorization.runtimeCli.sha256 -cne
|
|
$runtimeCliHash -or
|
|
([string]$verifiedAuthorization.runtimeCli.signerThumbprint).
|
|
ToUpperInvariant() -cne $runtimeSigner -or
|
|
$verifiedAuthorization.runtimeCli.requiresElevation -ne $false -or
|
|
$verifiedAuthorization.runtimeCli.bridgeOnly -ne $true -or
|
|
$verifiedAuthorization.runtimeCli.databaseDirectAccess -ne $false -or
|
|
[string]$verifiedAuthorization.runtimeCli.sessionSource -cne
|
|
'current_logged_in_erp_process' -or
|
|
[string]$verifiedAuthorization.verifierCli.sha256 -cne
|
|
$verifierCliHash -or
|
|
([string]$verifiedAuthorization.verifierCli.signerThumbprint).ToUpperInvariant() -cne
|
|
$verifierSigner -or
|
|
$verifiedAuthorization.signatureVerified -ne $true -or
|
|
$verifiedAuthorization.uatAuthorized -ne $true -or
|
|
$verifiedAuthorization.productionReady -ne $false) {
|
|
Throw-CampaignCheckError 'uat_authorization_contract_mismatch'
|
|
}
|
|
|
|
$campaignJson = Read-LockedJson $campaign 'campaign_json_invalid'
|
|
if (-not (Test-ExactProperties $campaignJson @(
|
|
'schemaVersion', 'contentSha256', 'content')) -or
|
|
[string]$campaignJson.schemaVersion -cne '1.1' -or
|
|
([string]$campaignJson.contentSha256) -cnotmatch '^[a-f0-9]{64}$') {
|
|
Throw-CampaignCheckError 'campaign_schema_invalid'
|
|
}
|
|
$campaignContent = $campaignJson.content
|
|
$campaignContentJson = $campaignContent | ConvertTo-Json -Depth 14 -Compress
|
|
if ((Get-Sha256Hex $utf8.GetBytes($campaignContentJson)) -cne
|
|
[string]$campaignJson.contentSha256) {
|
|
Throw-CampaignCheckError 'campaign_content_hash_mismatch'
|
|
}
|
|
if (-not (Test-ExactProperties $campaignContent @(
|
|
'packageType', 'campaignId', 'createdAtUnixSeconds',
|
|
'caseCatalogSha256', 'authorization', 'safety', 'directories',
|
|
'workflows', 'productionReady')) -or
|
|
[string]$campaignContent.packageType -cne 'workflow_write_uat_campaign' -or
|
|
([string]$campaignContent.campaignId) -cnotmatch
|
|
'^[A-Za-z0-9][A-Za-z0-9_.-]{7,63}$' -or
|
|
[string]$campaignContent.caseCatalogSha256 -cne $caseCatalogHash -or
|
|
$campaignContent.productionReady -ne $false -or
|
|
-not (Test-ExactProperties $campaignContent.authorization @(
|
|
'sourceSha256', 'contentSha256', 'authorizationIdSha256',
|
|
'customerId', 'environmentId', 'environmentClass',
|
|
'issuedAtUnixSeconds', 'expiresAtUnixSeconds', 'verifierCliSha256',
|
|
'verifierCliSignerThumbprint', 'runtimeCliVersion',
|
|
'runtimeCliSha256', 'runtimeCliSignerThumbprint', 'userIdSha256',
|
|
'userNameSha256', 'databaseScopeFingerprint')) -or
|
|
[string]$campaignContent.authorization.sourceSha256 -cne
|
|
[string]$verifiedAuthorization.sourceSha256 -or
|
|
[string]$campaignContent.authorization.contentSha256 -cne
|
|
[string]$verifiedAuthorization.contentSha256 -or
|
|
[string]$campaignContent.authorization.authorizationIdSha256 -cne
|
|
[string]$verifiedAuthorization.authorizationIdSha256 -or
|
|
[string]$campaignContent.authorization.customerId -cne
|
|
[string]$verifiedAuthorization.customerId -or
|
|
[string]$campaignContent.authorization.environmentId -cne
|
|
[string]$verifiedAuthorization.environmentId -or
|
|
[string]$campaignContent.authorization.environmentClass -cne 'recoverable_uat' -or
|
|
[int64]$campaignContent.authorization.issuedAtUnixSeconds -ne
|
|
(ConvertTo-UnixSeconds $verifiedAuthorization.issuedAtUtc) -or
|
|
[int64]$campaignContent.authorization.expiresAtUnixSeconds -ne
|
|
(ConvertTo-UnixSeconds $verifiedAuthorization.expiresAtUtc) -or
|
|
[string]$campaignContent.authorization.verifierCliSha256 -cne
|
|
$verifierCliHash -or
|
|
([string]$campaignContent.authorization.verifierCliSignerThumbprint).ToUpperInvariant() -cne
|
|
$verifierSigner -or
|
|
[string]$campaignContent.authorization.runtimeCliVersion -cne
|
|
$ExpectedRuntimeCliVersion -or
|
|
[string]$campaignContent.authorization.runtimeCliSha256 -cne
|
|
$runtimeCliHash -or
|
|
([string]$campaignContent.authorization.runtimeCliSignerThumbprint).
|
|
ToUpperInvariant() -cne $runtimeSigner -or
|
|
[string]$campaignContent.authorization.runtimeCliVersion -cne
|
|
[string]$verifiedAuthorization.runtimeCli.version -or
|
|
[string]$campaignContent.authorization.runtimeCliSha256 -cne
|
|
[string]$verifiedAuthorization.runtimeCli.sha256 -or
|
|
([string]$campaignContent.authorization.runtimeCliSignerThumbprint).
|
|
ToUpperInvariant() -cne
|
|
([string]$verifiedAuthorization.runtimeCli.signerThumbprint).
|
|
ToUpperInvariant() -or
|
|
[string]$campaignContent.authorization.userIdSha256 -cne
|
|
[string]$verifiedAuthorization.erpScope.userIdSha256 -or
|
|
[string]$campaignContent.authorization.userNameSha256 -cne
|
|
[string]$verifiedAuthorization.erpScope.userNameSha256 -or
|
|
[string]$campaignContent.authorization.databaseScopeFingerprint -cne
|
|
[string]$verifiedAuthorization.erpScope.databaseScopeFingerprint) {
|
|
Throw-CampaignCheckError 'campaign_authorization_mismatch'
|
|
}
|
|
$nowUnixSeconds = ConvertTo-UnixSeconds ([DateTimeOffset]::UtcNow)
|
|
if ([int64]$campaignContent.createdAtUnixSeconds -lt
|
|
[int64]$campaignContent.authorization.issuedAtUnixSeconds -or
|
|
[int64]$campaignContent.createdAtUnixSeconds -gt
|
|
[int64]$campaignContent.authorization.expiresAtUnixSeconds -or
|
|
[int64]$campaignContent.createdAtUnixSeconds -gt ($nowUnixSeconds + 300)) {
|
|
Throw-CampaignCheckError 'campaign_created_time_invalid'
|
|
}
|
|
if (-not (Test-ExactProperties $campaignContent.safety @(
|
|
'productionUseProhibited', 'automaticDatabaseWrites',
|
|
'oneCaseAtATime', 'operatorStagingRequired',
|
|
'authorizationReverificationRequiredBeforeResume',
|
|
'tokenMaterialIncluded', 'idempotencyMaterialIncluded',
|
|
'privateBusinessInputIncluded',
|
|
'resumeStateDerivedOnlyFromEvidence')) -or
|
|
$campaignContent.safety.productionUseProhibited -ne $true -or
|
|
$campaignContent.safety.automaticDatabaseWrites -ne $false -or
|
|
$campaignContent.safety.oneCaseAtATime -ne $true -or
|
|
$campaignContent.safety.operatorStagingRequired -ne $true -or
|
|
$campaignContent.safety.authorizationReverificationRequiredBeforeResume -ne $true -or
|
|
$campaignContent.safety.tokenMaterialIncluded -ne $false -or
|
|
$campaignContent.safety.idempotencyMaterialIncluded -ne $false -or
|
|
$campaignContent.safety.privateBusinessInputIncluded -ne $false -or
|
|
$campaignContent.safety.resumeStateDerivedOnlyFromEvidence -ne $true -or
|
|
-not (Test-ExactProperties $campaignContent.directories @(
|
|
'privateInput', 'evidence')) -or
|
|
[string]$campaignContent.directories.privateInput -cne 'private-input' -or
|
|
[string]$campaignContent.directories.evidence -cne 'evidence') {
|
|
Throw-CampaignCheckError 'campaign_safety_invalid'
|
|
}
|
|
|
|
$authorizationJson = Read-LockedJson $authorization 'uat_authorization_json_invalid'
|
|
$vaultJson = Read-LockedJson $vault 'uat_token_vault_json_invalid'
|
|
if ($null -eq $authorizationJson.content -or
|
|
-not (Test-ExactProperties $authorizationJson.content.erpScope @(
|
|
'accountBook', 'subSystemId', 'userId', 'userName',
|
|
'databaseScopeFingerprint'))) {
|
|
Throw-CampaignCheckError 'uat_authorization_scope_invalid'
|
|
}
|
|
$signedScope = $authorizationJson.content.erpScope
|
|
foreach ($scopeField in @(
|
|
'accountBook', 'subSystemId', 'userId', 'userName')) {
|
|
$scopeValue = [string]$signedScope.$scopeField
|
|
if ([string]::IsNullOrWhiteSpace($scopeValue) -or
|
|
$scopeValue.Length -gt 256 -or
|
|
$scopeValue -cne $scopeValue.Trim()) {
|
|
Throw-CampaignCheckError 'uat_authorization_scope_invalid'
|
|
}
|
|
foreach ($character in $scopeValue.ToCharArray()) {
|
|
if ([char]::IsControl($character)) {
|
|
Throw-CampaignCheckError 'uat_authorization_scope_invalid'
|
|
}
|
|
}
|
|
}
|
|
if (([string]$signedScope.databaseScopeFingerprint).ToLowerInvariant() -cne
|
|
[string]$verifiedAuthorization.erpScope.databaseScopeFingerprint -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.accountBook))) -cne
|
|
[string]$verifiedAuthorization.erpScope.accountBookSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.subSystemId))) -cne
|
|
[string]$verifiedAuthorization.erpScope.subSystemIdSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.userId))) -cne
|
|
[string]$verifiedAuthorization.erpScope.userIdSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.userName))) -cne
|
|
[string]$verifiedAuthorization.erpScope.userNameSha256) {
|
|
Throw-CampaignCheckError 'uat_authorization_scope_invalid'
|
|
}
|
|
if (-not (Test-ExactProperties $vaultJson @(
|
|
'schemaVersion', 'authorizationId', 'protectedForUserSid',
|
|
'protectionScope', 'createdAtUtc', 'entries')) -or
|
|
[string]$vaultJson.schemaVersion -cne '1.0' -or
|
|
[string]$vaultJson.authorizationId -cne
|
|
[string]$verifiedAuthorization.authorizationId -or
|
|
[string]$vaultJson.protectedForUserSid -cne $identity.User.Value -or
|
|
[string]$vaultJson.protectionScope -cne
|
|
'dpapi_current_user_high_integrity') {
|
|
Throw-CampaignCheckError 'uat_token_vault_contract_invalid'
|
|
}
|
|
$signedCaseByCode = @{}
|
|
foreach ($workflow in @($authorizationJson.content.workflows)) {
|
|
foreach ($case in @($workflow.cases)) {
|
|
$caseCode = [string]$case.caseCode
|
|
if ($signedCaseByCode.ContainsKey($caseCode) -or
|
|
([string]$case.tokenSha256) -cnotmatch '^[a-f0-9]{64}$') {
|
|
Throw-CampaignCheckError 'uat_authorization_case_contract_invalid'
|
|
}
|
|
$signedCaseByCode[$caseCode] = [pscustomobject]@{
|
|
Workflow = [string]$workflow.workflow
|
|
CommandName = [string]$case.expectedCommandName
|
|
TokenSha256 = [string]$case.tokenSha256
|
|
}
|
|
}
|
|
}
|
|
$vaultKeys = @{}
|
|
foreach ($entry in @($vaultJson.entries)) {
|
|
if (-not (Test-ExactProperties $entry @(
|
|
'workflow', 'caseCode', 'protectedTokenBase64')) -or
|
|
([string]$entry.protectedTokenBase64) -cnotmatch
|
|
'^[A-Za-z0-9+/]{64,4096}={0,2}$') {
|
|
Throw-CampaignCheckError 'uat_token_vault_entry_invalid'
|
|
}
|
|
$key = [string]$entry.workflow + '|' + [string]$entry.caseCode
|
|
if ($vaultKeys.ContainsKey($key)) {
|
|
Throw-CampaignCheckError 'uat_token_vault_entry_invalid'
|
|
}
|
|
$vaultKeys[$key] = $true
|
|
}
|
|
if ($vaultKeys.Count -ne $signedCaseByCode.Count) {
|
|
Throw-CampaignCheckError 'uat_token_vault_coverage_invalid'
|
|
}
|
|
foreach ($code in $signedCaseByCode.Keys) {
|
|
$signed = $signedCaseByCode[$code]
|
|
if (-not $vaultKeys.ContainsKey($signed.Workflow + '|' + $code)) {
|
|
Throw-CampaignCheckError 'uat_token_vault_coverage_invalid'
|
|
}
|
|
}
|
|
|
|
$health = Invoke-TrustedCli $runtimeCli.Path @(
|
|
'bridge', 'health', '--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
([string]$signedScope.databaseScopeFingerprint).ToLowerInvariant(),
|
|
'--expected-user-id', [string]$signedScope.userId,
|
|
'--expected-user-name', [string]$signedScope.userName,
|
|
'--expected-account-book', [string]$signedScope.accountBook,
|
|
'--expected-subsystem-id', [string]$signedScope.subSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', ('campaign-health-' + [Guid]::NewGuid().ToString('N'))
|
|
) ([Math]::Min(330000, $BridgeTimeoutMilliseconds + 30000))
|
|
$healthUat = $health.workflowUat
|
|
if ($null -eq $healthUat -or $healthUat.enabled -ne $true -or
|
|
[string]$healthUat.sourceSha256 -cne $authorizationHash -or
|
|
[string]$healthUat.authorizationIdSha256 -cne
|
|
[string]$verifiedAuthorization.authorizationIdSha256 -or
|
|
$healthUat.generalCapabilitiesHidden -ne $true) {
|
|
Throw-CampaignCheckError 'bridge_uat_authorization_mismatch'
|
|
}
|
|
|
|
$privateInputDirectory = Join-Path $campaignRoot 'private-input'
|
|
$evidenceDirectory = Join-Path $campaignRoot 'evidence'
|
|
if (-not [IO.Directory]::Exists($privateInputDirectory) -or
|
|
-not [IO.Directory]::Exists($evidenceDirectory)) {
|
|
Throw-CampaignCheckError 'campaign_directory_layout_invalid'
|
|
}
|
|
Assert-NoReparseDirectoryChain $privateInputDirectory 'campaign_directory_layout_invalid'
|
|
Assert-NoReparseDirectoryChain $evidenceDirectory 'campaign_directory_layout_invalid'
|
|
$rootItems = @(Get-ChildItem -LiteralPath $campaignRoot -Force)
|
|
if ($rootItems.Count -ne 3) {
|
|
Throw-CampaignCheckError 'campaign_directory_layout_invalid'
|
|
}
|
|
foreach ($item in $rootItems) {
|
|
$validRootItem = ($item.Name -ceq 'campaign.json' -and -not $item.PSIsContainer) -or
|
|
($item.Name -cin @('private-input', 'evidence') -and $item.PSIsContainer)
|
|
if (-not $validRootItem -or
|
|
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
|
|
Throw-CampaignCheckError 'campaign_directory_layout_invalid'
|
|
}
|
|
}
|
|
|
|
$expectedInputNames = @{}
|
|
$expectedEvidenceNames = @{}
|
|
$allowedEvidenceNames = @{}
|
|
$completedCodes = @{}
|
|
$workflowStatuses = New-Object System.Collections.Generic.List[object]
|
|
$nextCase = $null
|
|
$campaignCodes = @{}
|
|
$workflows = @($campaignContent.workflows)
|
|
if ($workflows.Count -lt 1 -or $workflows.Count -gt 2) {
|
|
Throw-CampaignCheckError 'campaign_workflow_coverage_invalid'
|
|
}
|
|
foreach ($workflow in $workflows) {
|
|
if (-not (Test-ExactProperties $workflow @(
|
|
'workflow', 'moduleCode', 'caseCount', 'caseIndexFile',
|
|
'assembledCasesFile', 'cases'))) {
|
|
Throw-CampaignCheckError 'campaign_workflow_schema_invalid'
|
|
}
|
|
$workflowName = [string]$workflow.workflow
|
|
$expectedCases = if ($workflowName -eq 'purchase') {
|
|
$purchaseCases
|
|
} elseif ($workflowName -eq 'leave') {
|
|
$leaveCases
|
|
} else { Throw-CampaignCheckError 'campaign_workflow_coverage_invalid' }
|
|
$authorizedWorkflow = @($verifiedAuthorization.workflows | Where-Object {
|
|
[string]$_.workflow -ceq $workflowName
|
|
})
|
|
if ($authorizedWorkflow.Count -ne 1 -or
|
|
[string]$workflow.moduleCode -cne
|
|
[string]$authorizedWorkflow[0].moduleCode -or
|
|
[int]$authorizedWorkflow[0].caseCount -ne $expectedCases.Count) {
|
|
Throw-CampaignCheckError 'campaign_workflow_authorization_mismatch'
|
|
}
|
|
if ([int]$workflow.caseCount -ne $expectedCases.Count -or
|
|
[string]$workflow.caseIndexFile -cne
|
|
('evidence/' + $workflowName + '-index.json') -or
|
|
[string]$workflow.assembledCasesFile -cne
|
|
('evidence/' + $workflowName + '-cases.json')) {
|
|
Throw-CampaignCheckError 'campaign_workflow_contract_invalid'
|
|
}
|
|
$indexPath = Join-Path $evidenceDirectory ($workflowName + '-index.json')
|
|
$indexFile = Open-LockedRegularFile `
|
|
$indexPath (64KB) ($workflowName + '-index.json') `
|
|
'campaign_case_index_invalid'
|
|
$indexJson = Read-LockedJson $indexFile 'campaign_case_index_invalid'
|
|
if (-not (Test-ExactProperties $indexJson @(
|
|
'schemaVersion', 'workflow', 'caseFiles')) -or
|
|
[string]$indexJson.schemaVersion -cne '1.0' -or
|
|
[string]$indexJson.workflow -cne $workflowName) {
|
|
Throw-CampaignCheckError 'campaign_case_index_invalid'
|
|
}
|
|
$indexCaseFiles = @($indexJson.caseFiles)
|
|
if ($indexCaseFiles.Count -ne $expectedCases.Count) {
|
|
Throw-CampaignCheckError 'campaign_case_index_invalid'
|
|
}
|
|
for ($caseIndex = 0; $caseIndex -lt $expectedCases.Count; $caseIndex++) {
|
|
if ([string]$indexCaseFiles[$caseIndex] -cne
|
|
($expectedCases[$caseIndex] + '.json')) {
|
|
Throw-CampaignCheckError 'campaign_case_index_invalid'
|
|
}
|
|
}
|
|
$cases = @($workflow.cases)
|
|
if ($cases.Count -ne $expectedCases.Count) {
|
|
Throw-CampaignCheckError 'campaign_case_coverage_invalid'
|
|
}
|
|
$allowedEvidenceNames[$workflowName + '-index.json'] = $true
|
|
$allowedEvidenceNames[$workflowName + '-cases.json'] = $true
|
|
$completedInWorkflow = 0
|
|
for ($index = 0; $index -lt $expectedCases.Count; $index++) {
|
|
$case = $cases[$index]
|
|
$caseCode = $expectedCases[$index]
|
|
$catalogCase = $catalogCaseByCode[$caseCode]
|
|
$expectedCaptureMode = if ($caseCode -in $derivedCases) {
|
|
'derived_audit'
|
|
} elseif ($caseCode -in $executeCases) {
|
|
'execute'
|
|
} else { 'plan_only' }
|
|
$expectedDependency = Get-Dependency $caseCode
|
|
$expectedAuditOutput = if ($caseCode -eq 'purchase_unique_match_commit') {
|
|
'purchase_audit_correlated.json'
|
|
} elseif ($caseCode -eq 'leave_create_draft_commit') {
|
|
'leave_audit_correlated.json'
|
|
} else { $null }
|
|
$expectedOperatorStage = if ($expectedCaptureMode -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 $expectedDependency) {
|
|
'relationship_fixture_required'
|
|
} else { 'case_fixture_required' }
|
|
$expectedMutation = if ($caseCode -in @(
|
|
'purchase_unique_match_commit', 'leave_create_draft_commit',
|
|
'leave_submit_separate_confirmation')) {
|
|
'positive'
|
|
} else { 'zero' }
|
|
if (-not (Test-ExactProperties $case @(
|
|
'sequence', 'caseCode', 'commandName', 'captureMode',
|
|
'execute', 'pauseAfterPlanForOperatorStaging',
|
|
'operatorStage', 'dependencyCaseCode',
|
|
'idempotencyPolicy', 'inputFile', 'evidenceFile',
|
|
'correlatedAuditEvidenceFile',
|
|
'requiresDbaReadOnlyObservation',
|
|
'expectedBusinessMutation')) -or
|
|
[int]$case.sequence -ne ($index + 1) -or
|
|
[string]$case.caseCode -cne $caseCode -or
|
|
[string]$case.evidenceFile -cne ($caseCode + '.json') -or
|
|
$case.requiresDbaReadOnlyObservation -ne $true -or
|
|
-not $signedCaseByCode.ContainsKey($caseCode) -or
|
|
[string]$case.commandName -cne
|
|
[string]$signedCaseByCode[$caseCode].CommandName -or
|
|
[string]$case.commandName -cne [string]$catalogCase.commandName -or
|
|
[string]$case.captureMode -cne $expectedCaptureMode -or
|
|
[string]$case.captureMode -cne [string]$catalogCase.captureMode -or
|
|
[bool]$case.execute -ne ($expectedCaptureMode -eq 'execute') -or
|
|
[bool]$case.pauseAfterPlanForOperatorStaging -ne
|
|
($caseCode -in $postPlanStagingCases) -or
|
|
[string]$case.operatorStage -cne $expectedOperatorStage -or
|
|
(($null -eq $case.dependencyCaseCode) -ne
|
|
($null -eq $expectedDependency)) -or
|
|
($null -ne $expectedDependency -and
|
|
[string]$case.dependencyCaseCode -cne $expectedDependency) -or
|
|
[string]$case.idempotencyPolicy -cne
|
|
(Get-IdempotencyPolicy $caseCode $expectedCaptureMode) -or
|
|
(($null -eq $case.correlatedAuditEvidenceFile) -ne
|
|
($null -eq $expectedAuditOutput)) -or
|
|
($null -ne $expectedAuditOutput -and
|
|
[string]$case.correlatedAuditEvidenceFile -cne
|
|
$expectedAuditOutput) -or
|
|
[string]$case.expectedBusinessMutation -cne $expectedMutation -or
|
|
[string]$case.expectedBusinessMutation -cne
|
|
[string]$catalogCase.expectedMutationPolicy -or
|
|
$campaignCodes.ContainsKey($caseCode)) {
|
|
Throw-CampaignCheckError 'campaign_case_contract_invalid'
|
|
}
|
|
$campaignCodes[$caseCode] = $true
|
|
$evidenceName = [string]$case.evidenceFile
|
|
$expectedEvidenceNames[$evidenceName] = $true
|
|
$allowedEvidenceNames[$evidenceName] = $true
|
|
if ($null -ne $case.inputFile) {
|
|
$expectedInput = 'private-input/' + $caseCode + '.json'
|
|
if ([string]$case.inputFile -cne $expectedInput) {
|
|
Throw-CampaignCheckError 'campaign_case_input_invalid'
|
|
}
|
|
$expectedInputNames[$caseCode + '.json'] = $true
|
|
}
|
|
elseif ($expectedCaptureMode -cne 'derived_audit') {
|
|
Throw-CampaignCheckError 'campaign_case_input_invalid'
|
|
}
|
|
$evidencePath = Join-Path $evidenceDirectory $evidenceName
|
|
if ([IO.File]::Exists($evidencePath)) {
|
|
$evidence = Open-LockedRegularFile `
|
|
$evidencePath (256KB) $evidenceName 'campaign_evidence_file_invalid'
|
|
$verifiedCase = Invoke-TrustedCli $verifierCli.Path @(
|
|
'adapters', 'verify-write-observation', '--input', $evidence.Path,
|
|
'--correlation-id', ('campaign-case-' + [Guid]::NewGuid().ToString('N'))
|
|
) $BridgeTimeoutMilliseconds
|
|
$signedCase = $signedCaseByCode[$caseCode]
|
|
$observedAtUnixSeconds = ConvertTo-UnixSeconds `
|
|
$verifiedCase.observedAtUtc
|
|
if ([string]$verifiedCase.packageType -cne
|
|
'workflow_write_case_observation' -or
|
|
[string]$verifiedCase.caseCode -cne $caseCode -or
|
|
[string]$verifiedCase.commandName -cne [string]$case.commandName -or
|
|
[string]$verifiedCase.uatAuthorizationSourceSha256 -cne
|
|
$authorizationHash -or
|
|
[string]$verifiedCase.uatAuthorizationContentSha256 -cne
|
|
[string]$verifiedAuthorization.contentSha256 -or
|
|
[string]$verifiedCase.uatAuthorizationIdSha256 -cne
|
|
[string]$verifiedAuthorization.authorizationIdSha256 -or
|
|
[string]$verifiedCase.uatTokenSha256 -cne
|
|
[string]$signedCase.TokenSha256 -or
|
|
[string]$verifiedCase.schemaVersion -cne '1.1' -or
|
|
[string]$verifiedCase.runtimeCliVersion -cne
|
|
$ExpectedRuntimeCliVersion -or
|
|
[string]$verifiedCase.runtimeCliSha256 -cne
|
|
$runtimeCliHash -or
|
|
([string]$verifiedCase.runtimeCliSignerThumbprint).
|
|
ToUpperInvariant() -cne $runtimeSigner -or
|
|
$observedAtUnixSeconds -lt
|
|
[int64]$campaignContent.authorization.issuedAtUnixSeconds -or
|
|
$observedAtUnixSeconds -gt
|
|
[int64]$campaignContent.authorization.expiresAtUnixSeconds -or
|
|
$verifiedCase.semanticsVerified -ne $true -or
|
|
$verifiedCase.rawIdentifiersEmitted -ne $false -or
|
|
$verifiedCase.productionReady -ne $false) {
|
|
Throw-CampaignCheckError 'campaign_evidence_binding_invalid'
|
|
}
|
|
$completedCodes[$caseCode] = $true
|
|
$completedInWorkflow++
|
|
}
|
|
}
|
|
$commitCode = if ($workflowName -eq 'purchase') {
|
|
'purchase_unique_match_commit'
|
|
} else { 'leave_create_draft_commit' }
|
|
$auditCode = if ($workflowName -eq 'purchase') {
|
|
'purchase_audit_correlated'
|
|
} else { 'leave_audit_correlated' }
|
|
if ($completedCodes.ContainsKey($commitCode) -ne
|
|
$completedCodes.ContainsKey($auditCode)) {
|
|
Throw-CampaignCheckError 'campaign_commit_audit_pair_incomplete'
|
|
}
|
|
$complete = $completedInWorkflow -eq $expectedCases.Count
|
|
if ($complete) {
|
|
$setVerification = Invoke-TrustedCli $verifierCli.Path @(
|
|
'adapters', 'verify-write-observations', '--input', $indexFile.Path,
|
|
'--correlation-id', ('campaign-set-' + [Guid]::NewGuid().ToString('N'))
|
|
) $BridgeTimeoutMilliseconds
|
|
if ([string]$setVerification.schemaVersion -cne '1.1' -or
|
|
[string]$setVerification.workflow -cne $workflowName -or
|
|
[int]$setVerification.caseCount -ne $expectedCases.Count -or
|
|
[string]$setVerification.runtimeCliVersion -cne
|
|
$ExpectedRuntimeCliVersion -or
|
|
[string]$setVerification.runtimeCliSha256 -cne
|
|
$runtimeCliHash -or
|
|
([string]$setVerification.runtimeCliSignerThumbprint).
|
|
ToUpperInvariant() -cne $runtimeSigner -or
|
|
$setVerification.coverageVerified -ne $true -or
|
|
$setVerification.crossCaseRelationshipsVerified -ne $true) {
|
|
Throw-CampaignCheckError 'campaign_case_set_invalid'
|
|
}
|
|
}
|
|
$workflowStatuses.Add([pscustomobject][ordered]@{
|
|
workflow = $workflowName
|
|
requiredCaseCount = $expectedCases.Count
|
|
completedCaseCount = $completedInWorkflow
|
|
complete = $complete
|
|
crossCaseRelationshipsVerified = $complete
|
|
})
|
|
}
|
|
if ($workflows.Count -ne @($verifiedAuthorization.workflows).Count -or
|
|
$campaignCodes.Count -ne $signedCaseByCode.Count) {
|
|
Throw-CampaignCheckError 'campaign_case_coverage_invalid'
|
|
}
|
|
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $evidenceDirectory -Force)) {
|
|
if ($file.PSIsContainer -or
|
|
(($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
|
-not $allowedEvidenceNames.ContainsKey($file.Name)) {
|
|
Throw-CampaignCheckError 'campaign_evidence_directory_contains_unknown_file'
|
|
}
|
|
}
|
|
foreach ($file in @(Get-ChildItem -LiteralPath $privateInputDirectory -Force)) {
|
|
if ($file.PSIsContainer -or
|
|
(($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) -or
|
|
-not $expectedInputNames.ContainsKey($file.Name) -or
|
|
$file.Length -le 0 -or $file.Length -gt 512KB) {
|
|
Throw-CampaignCheckError 'campaign_private_input_invalid'
|
|
}
|
|
}
|
|
|
|
foreach ($workflow in $workflows) {
|
|
foreach ($case in @($workflow.cases)) {
|
|
$caseCode = [string]$case.caseCode
|
|
if ($completedCodes.ContainsKey($caseCode)) { continue }
|
|
$dependency = if ($null -eq $case.dependencyCaseCode) {
|
|
$null
|
|
} else { [string]$case.dependencyCaseCode }
|
|
if ($null -ne $dependency -and
|
|
-not $completedCodes.ContainsKey($dependency)) {
|
|
continue
|
|
}
|
|
if ([string]$case.captureMode -ceq 'derived_audit') {
|
|
Throw-CampaignCheckError 'campaign_derived_audit_missing'
|
|
}
|
|
$inputPath = Join-Path $campaignRoot ([string]$case.inputFile)
|
|
$catalogCase = $catalogCaseByCode[$caseCode]
|
|
$nextCase = [pscustomobject][ordered]@{
|
|
workflow = [string]$workflow.workflow
|
|
caseCode = $caseCode
|
|
commandName = [string]$case.commandName
|
|
captureMode = [string]$case.captureMode
|
|
inputFile = [string]$case.inputFile
|
|
inputReady = [IO.File]::Exists($inputPath)
|
|
evidenceFile = 'evidence/' + [string]$case.evidenceFile
|
|
correlatedAuditEvidenceFile = $case.correlatedAuditEvidenceFile
|
|
execute = [bool]$case.execute
|
|
pauseAfterPlanForOperatorStaging =
|
|
[bool]$case.pauseAfterPlanForOperatorStaging
|
|
operatorStage = [string]$case.operatorStage
|
|
idempotencyPolicy = [string]$case.idempotencyPolicy
|
|
requiresDbaReadOnlyObservation = $true
|
|
operatorGuide = [pscustomobject][ordered]@{
|
|
title = [string]$catalogCase.title
|
|
expectedResultCode = [string]$catalogCase.expectedResultCode
|
|
expectedIssueCode = $catalogCase.expectedIssueCode
|
|
expectedMutationPolicy =
|
|
[string]$catalogCase.expectedMutationPolicy
|
|
nativeConfirmationPolicy =
|
|
[string]$catalogCase.nativeConfirmationPolicy
|
|
minimumAuditEventCount =
|
|
[int]$catalogCase.minimumAuditEventCount
|
|
sourceDocumentProofRequired =
|
|
[bool]$catalogCase.sourceDocumentProofRequired
|
|
primaryRole = [string]$catalogCase.primaryRole
|
|
supportingRoles = @($catalogCase.supportingRoles)
|
|
fixtureCode = [string]$catalogCase.fixtureCode
|
|
preconditions = @($catalogCase.preconditions)
|
|
operatorSteps = @($catalogCase.operatorSteps)
|
|
dbaReadOnlyChecks = @($catalogCase.dbaReadOnlyChecks)
|
|
cleanupSteps = @($catalogCase.cleanupSteps)
|
|
retryPolicy = [string]$catalogCase.retryPolicy
|
|
}
|
|
}
|
|
break
|
|
}
|
|
if ($null -ne $nextCase) { break }
|
|
}
|
|
|
|
$completedCount = $completedCodes.Count
|
|
$requiredCount = $campaignCodes.Count
|
|
$complete = $completedCount -eq $requiredCount
|
|
if (-not $complete -and $null -eq $nextCase) {
|
|
Throw-CampaignCheckError 'campaign_dependency_deadlock'
|
|
}
|
|
[pscustomobject][ordered]@{
|
|
packageType = 'workflow_write_uat_campaign_checkpoint'
|
|
schemaVersion = '1.1'
|
|
campaignId = [string]$campaignContent.campaignId
|
|
campaignContentSha256 = [string]$campaignJson.contentSha256
|
|
caseCatalogSha256 = $caseCatalogHash
|
|
authorizationSignatureVerified = $true
|
|
authorizationActive = $true
|
|
verifierCliSha256 = $verifierCliHash
|
|
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
|
runtimeCliSha256 = $runtimeCliHash
|
|
runtimeCliSignerThumbprint = $runtimeSigner
|
|
bridgeAuthorizationMatched = $true
|
|
tokenVaultAclAndCoverageVerified = $true
|
|
productionUseProhibited = $true
|
|
automaticDatabaseWrites = $false
|
|
completedCaseCount = $completedCount
|
|
requiredCaseCount = $requiredCount
|
|
workflows = @($workflowStatuses)
|
|
nextCase = $nextCase
|
|
complete = $complete
|
|
readyForNextCase = (-not $complete -and $nextCase.inputReady)
|
|
productionReady = $false
|
|
} | ConvertTo-Json -Depth 10
|
|
}
|
|
finally {
|
|
foreach ($lock in @($locks)) {
|
|
if ($null -ne $lock) { $lock.Dispose() }
|
|
}
|
|
}
|