1379 lines
56 KiB
PowerShell
1379 lines
56 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
|
|
[string]$CaseCode,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')]
|
|
[string]$CommandName,
|
|
|
|
[Parameter(Mandatory = $true)][string]$CommandInputFile,
|
|
[Parameter(Mandatory = $true)][string]$OutputPath,
|
|
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
|
[Parameter(Mandatory = $true)][string]$RuntimeCliPath,
|
|
|
|
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
|
|
[Parameter(Mandatory = $true)][string]$UatTokenVaultPath,
|
|
[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,
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateRange(1, 2147483647)]
|
|
[int]$ErpProcessId,
|
|
|
|
[switch]$Execute,
|
|
[Security.SecureString]$IdempotencyKey,
|
|
[string]$IdempotencyKeyFile = '',
|
|
[switch]$PauseAfterPlanForOperatorStaging,
|
|
[switch]$NonInteractive,
|
|
|
|
[ValidateRange(-1, 1000)]
|
|
[int]$BusinessMutationCount = -1,
|
|
[Nullable[bool]]$NativeConfirmationObserved = $null,
|
|
[ValidateRange(-1, 1000)]
|
|
[int]$AuditEventCount = -1,
|
|
[Nullable[bool]]$SourceDocumentWritePayloadBound = $null,
|
|
[ValidateRange(-1, 1000)]
|
|
[int]$SourceDocumentAuditCount = -1,
|
|
|
|
[string]$CorrelatedAuditOutputPath = '',
|
|
[string]$RestrictedWorkingRoot = '',
|
|
|
|
[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_write_case_capture_failed:windows_powershell_51_required'
|
|
}
|
|
$windowsIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
$windowsPrincipal = [Security.Principal.WindowsPrincipal]::new($windowsIdentity)
|
|
if (-not $windowsPrincipal.IsInRole(
|
|
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
throw 'workflow_write_case_capture_failed:elevated_operator_required'
|
|
}
|
|
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
|
$maximumResponseCharacters = 4 * 1024 * 1024
|
|
$rawDirectory = $null
|
|
$idempotencyPlain = $null
|
|
$uatToken = $null
|
|
$uatAuditTokenSha256 = $null
|
|
$verifierCliLock = $null
|
|
$runtimeCliLock = $null
|
|
$uatAuthorizationLock = $null
|
|
$uatTokenVaultLock = $null
|
|
$summaryJson = $null
|
|
$stagedOutputs = New-Object System.Collections.Generic.List[string]
|
|
$publishedOutputs = New-Object System.Collections.Generic.List[string]
|
|
|
|
function Throw-CaptureError([string]$Code) {
|
|
throw ("workflow_write_case_capture_failed:" + $Code)
|
|
}
|
|
|
|
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
|
|
$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-CaptureError $Code
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
|
|
function Resolve-RegularFile(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$Code
|
|
) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if (-not [IO.File]::Exists($full)) { Throw-CaptureError $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)) {
|
|
Throw-CaptureError $Code
|
|
}
|
|
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError $Code
|
|
}
|
|
}
|
|
|
|
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if ([IO.Path]::GetExtension($full) -ine '.json' -or
|
|
[IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) {
|
|
Throw-CaptureError $Code
|
|
}
|
|
$directory = [IO.Path]::GetDirectoryName($full)
|
|
Assert-NoReparseDirectoryChain $directory $Code
|
|
return $full
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError $Code
|
|
}
|
|
}
|
|
|
|
function New-RestrictedDirectory([string]$Root) {
|
|
$path = $null
|
|
try {
|
|
$fullRoot = [IO.Path]::GetFullPath($Root)
|
|
[IO.Directory]::CreateDirectory($fullRoot) | Out-Null
|
|
Assert-NoReparseDirectoryChain $fullRoot 'restricted_working_root_invalid'
|
|
$path = Join-Path $fullRoot ('case-' + [Guid]::NewGuid().ToString('N'))
|
|
[IO.Directory]::CreateDirectory($path) | Out-Null
|
|
|
|
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().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
|
|
$propagation = [Security.AccessControl.PropagationFlags]::None
|
|
$allow = [Security.AccessControl.AccessControlType]::Allow
|
|
foreach ($identity in @($currentUser, $localSystem)) {
|
|
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
|
$identity,
|
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
$inheritance,
|
|
$propagation,
|
|
$allow)
|
|
$security.AddAccessRule($rule)
|
|
}
|
|
[IO.Directory]::SetAccessControl($path, $security)
|
|
$verified = [IO.Directory]::GetAccessControl($path)
|
|
if (-not $verified.AreAccessRulesProtected) {
|
|
Throw-CaptureError 'restricted_working_acl_invalid'
|
|
}
|
|
return $path
|
|
}
|
|
catch {
|
|
if ($null -ne $path -and [IO.Directory]::Exists($path)) {
|
|
try { [IO.Directory]::Delete($path, $true) } catch { }
|
|
}
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError 'restricted_working_acl_invalid'
|
|
}
|
|
}
|
|
|
|
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 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 Get-Sha256Hex([byte[]]$Bytes) {
|
|
$sha = [Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
return ([BitConverter]::ToString(
|
|
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
|
|
}
|
|
finally { $sha.Dispose() }
|
|
}
|
|
|
|
function Assert-RestrictedVaultAcl([string]$Path) {
|
|
try {
|
|
$sections = [Security.AccessControl.AccessControlSections]::All
|
|
$acl = [IO.File]::GetAccessControl($Path, $sections)
|
|
$owner = $acl.GetOwner(
|
|
[Security.Principal.SecurityIdentifier]).Value
|
|
$currentSid = $windowsIdentity.User.Value
|
|
$systemSid = [Security.Principal.SecurityIdentifier]::new(
|
|
[Security.Principal.WellKnownSidType]::LocalSystemSid,
|
|
$null).Value
|
|
if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid) {
|
|
Throw-CaptureError 'uat_token_vault_acl_invalid'
|
|
}
|
|
$rules = @($acl.GetAccessRules(
|
|
$true,
|
|
$true,
|
|
[Security.Principal.SecurityIdentifier]))
|
|
if ($rules.Count -ne 2) {
|
|
Throw-CaptureError 'uat_token_vault_acl_invalid'
|
|
}
|
|
$seen = @{}
|
|
foreach ($rule in $rules) {
|
|
$sid = [string]$rule.IdentityReference.Value
|
|
if ($rule.IsInherited -or
|
|
$rule.AccessControlType -ne
|
|
[Security.AccessControl.AccessControlType]::Allow -or
|
|
($sid -cne $currentSid -and $sid -cne $systemSid) -or
|
|
(($rule.FileSystemRights -band
|
|
[Security.AccessControl.FileSystemRights]::FullControl) -ne
|
|
[Security.AccessControl.FileSystemRights]::FullControl) -or
|
|
$seen.ContainsKey($sid)) {
|
|
Throw-CaptureError 'uat_token_vault_acl_invalid'
|
|
}
|
|
$seen[$sid] = $true
|
|
}
|
|
if (-not $seen.ContainsKey($currentSid) -or
|
|
-not $seen.ContainsKey($systemSid)) {
|
|
Throw-CaptureError 'uat_token_vault_acl_invalid'
|
|
}
|
|
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
|
|
if ($sddl -cnotmatch 'S:.*\(ML;;NW;;;HI\)') {
|
|
Throw-CaptureError 'uat_token_vault_integrity_invalid'
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError 'uat_token_vault_acl_invalid'
|
|
}
|
|
}
|
|
|
|
function Get-PreparationCommand(
|
|
[string]$CaptureCaseCode,
|
|
[string]$TargetCommandName
|
|
) {
|
|
if ($TargetCommandName -ceq 'purchase.invoice.create' -and
|
|
$CaptureCaseCode -cne 'purchase_permission_denied') {
|
|
return 'purchase.invoice.resolve'
|
|
}
|
|
if ($TargetCommandName -ceq 'hr.leave.create' -and
|
|
$CaptureCaseCode -cnotin @(
|
|
'leave_resolution_proof_bypass_blocked',
|
|
'leave_local_time_zone_rejected',
|
|
'leave_permission_denied')) {
|
|
return 'hr.leave.resolve'
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Read-VerifiedUatGrant(
|
|
[object]$VerifiedData,
|
|
[string]$AuthorizationPath,
|
|
[string]$VaultPath,
|
|
[string]$CaptureCaseCode,
|
|
[string]$TargetCommandName
|
|
) {
|
|
try {
|
|
if ($null -eq $VerifiedData -or
|
|
[string]$VerifiedData.packageType -cne
|
|
'workflow_write_uat_authorization' -or
|
|
[string]$VerifiedData.schemaVersion -cne '1.2' -or
|
|
[string]$VerifiedData.sourceSha256 -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
|
|
[string]$VerifiedData.verifierCli.fileName -cne 'lserp-cli.exe' -or
|
|
[string]$VerifiedData.verifierCli.sha256 -cne
|
|
$expectedVerifierCliHash -or
|
|
[string]$VerifiedData.verifierCli.signerThumbprint -cne
|
|
$expectedVerifierCliSigner -or
|
|
$VerifiedData.verifierCli.requiresElevation -ne $true -or
|
|
[string]$VerifiedData.runtimeCli.fileName -cne
|
|
'lserp-agent-cli.exe' -or
|
|
[string]$VerifiedData.runtimeCli.version -cne
|
|
$ExpectedRuntimeCliVersion -or
|
|
[string]$VerifiedData.runtimeCli.sha256 -cne
|
|
$expectedRuntimeCliHash -or
|
|
[string]$VerifiedData.runtimeCli.signerThumbprint -cne
|
|
$expectedRuntimeCliSigner -or
|
|
$VerifiedData.runtimeCli.requiresElevation -ne $false -or
|
|
$VerifiedData.runtimeCli.bridgeOnly -ne $true -or
|
|
$VerifiedData.runtimeCli.databaseDirectAccess -ne $false -or
|
|
[string]$VerifiedData.runtimeCli.sessionSource -cne
|
|
'current_logged_in_erp_process' -or
|
|
$VerifiedData.signatureVerified -ne $true -or
|
|
$VerifiedData.uatAuthorized -ne $true -or
|
|
$VerifiedData.productionReady -ne $false) {
|
|
Throw-CaptureError 'uat_authorization_verification_invalid'
|
|
}
|
|
|
|
$authorizationText = [IO.File]::ReadAllText($AuthorizationPath, $utf8)
|
|
$authorization = $authorizationText | ConvertFrom-Json
|
|
$issuedAtMatches = [Text.RegularExpressions.Regex]::Matches(
|
|
$authorizationText,
|
|
'"issuedAtUtc"\s*:\s*"(?<value>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"',
|
|
[Text.RegularExpressions.RegexOptions]::CultureInvariant)
|
|
$expiresAtMatches = [Text.RegularExpressions.Regex]::Matches(
|
|
$authorizationText,
|
|
'"expiresAtUtc"\s*:\s*"(?<value>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"',
|
|
[Text.RegularExpressions.RegexOptions]::CultureInvariant)
|
|
if (-not (Test-ExactProperties $authorization @(
|
|
'schemaVersion', 'contentSha256', 'signatureAlgorithm',
|
|
'certificateThumbprint', 'signatureBase64', 'content')) -or
|
|
[string]$authorization.schemaVersion -cne '1.2' -or
|
|
$null -eq $authorization.content -or
|
|
-not (Test-ExactProperties $authorization.content.erpScope @(
|
|
'accountBook', 'subSystemId', 'userId', 'userName',
|
|
'databaseScopeFingerprint')) -or
|
|
[string]$authorization.content.erpScope.databaseScopeFingerprint -cne
|
|
[string]$VerifiedData.erpScope.databaseScopeFingerprint -or
|
|
[string]$authorization.content.authorizationId -cne
|
|
[string]$VerifiedData.authorizationId -or
|
|
$issuedAtMatches.Count -ne 1 -or $expiresAtMatches.Count -ne 1) {
|
|
Throw-CaptureError 'uat_authorization_contract_invalid'
|
|
}
|
|
$signedScope = $authorization.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-CaptureError 'uat_authorization_scope_invalid'
|
|
}
|
|
foreach ($character in $scopeValue.ToCharArray()) {
|
|
if ([char]::IsControl($character)) {
|
|
Throw-CaptureError 'uat_authorization_scope_invalid'
|
|
}
|
|
}
|
|
}
|
|
if ((Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.accountBook))) -cne
|
|
[string]$VerifiedData.erpScope.accountBookSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.subSystemId))) -cne
|
|
[string]$VerifiedData.erpScope.subSystemIdSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.userId))) -cne
|
|
[string]$VerifiedData.erpScope.userIdSha256 -or
|
|
(Get-Sha256Hex ($utf8.GetBytes(
|
|
[string]$signedScope.userName))) -cne
|
|
[string]$VerifiedData.erpScope.userNameSha256) {
|
|
Throw-CaptureError 'uat_authorization_scope_invalid'
|
|
}
|
|
$workflowName = if ($CaptureCaseCode.StartsWith(
|
|
'purchase_', [StringComparison]::Ordinal)) {
|
|
'purchase'
|
|
} elseif ($CaptureCaseCode.StartsWith(
|
|
'leave_', [StringComparison]::Ordinal)) {
|
|
'leave'
|
|
} else {
|
|
Throw-CaptureError 'uat_case_not_authorized'
|
|
}
|
|
$allCases = New-Object System.Collections.Generic.List[object]
|
|
foreach ($workflow in @($authorization.content.workflows)) {
|
|
foreach ($case in @($workflow.cases)) {
|
|
$allCases.Add([pscustomobject]@{
|
|
Workflow = [string]$workflow.workflow
|
|
Case = $case
|
|
})
|
|
}
|
|
}
|
|
$matches = @($allCases | Where-Object {
|
|
$_.Workflow -ceq $workflowName -and
|
|
[string]$_.Case.caseCode -ceq $CaptureCaseCode
|
|
})
|
|
if ($matches.Count -ne 1) {
|
|
Throw-CaptureError 'uat_case_not_authorized'
|
|
}
|
|
$caseAuthorization = $matches[0].Case
|
|
if (-not (Test-ExactProperties $caseAuthorization @(
|
|
'caseCode', 'expectedCommandName',
|
|
'allowedCommands', 'tokenSha256')) -or
|
|
[string]$caseAuthorization.expectedCommandName -cne
|
|
$TargetCommandName -or
|
|
[string]$caseAuthorization.tokenSha256 -cnotmatch
|
|
'^[a-f0-9]{64}$') {
|
|
Throw-CaptureError 'uat_case_command_mismatch'
|
|
}
|
|
$allowedCommands = @($caseAuthorization.allowedCommands |
|
|
ForEach-Object { [string]$_ })
|
|
$preparationCommand = Get-PreparationCommand `
|
|
$CaptureCaseCode $TargetCommandName
|
|
if ($allowedCommands -cnotcontains $TargetCommandName -or
|
|
($null -ne $preparationCommand -and
|
|
$allowedCommands -cnotcontains $preparationCommand)) {
|
|
Throw-CaptureError 'uat_case_command_mismatch'
|
|
}
|
|
|
|
Assert-RestrictedVaultAcl $VaultPath
|
|
$vault = [IO.File]::ReadAllText($VaultPath, $utf8) | ConvertFrom-Json
|
|
if (-not (Test-ExactProperties $vault @(
|
|
'schemaVersion', 'authorizationId',
|
|
'protectedForUserSid', 'protectionScope',
|
|
'createdAtUtc', 'entries')) -or
|
|
[string]$vault.schemaVersion -cne '1.0' -or
|
|
[string]$vault.authorizationId -cne
|
|
[string]$VerifiedData.authorizationId -or
|
|
[string]$vault.protectedForUserSid -cne
|
|
$windowsIdentity.User.Value -or
|
|
[string]$vault.protectionScope -cne
|
|
'dpapi_current_user_high_integrity') {
|
|
Throw-CaptureError 'uat_token_vault_contract_invalid'
|
|
}
|
|
$vaultEntries = @($vault.entries)
|
|
if ($vaultEntries.Count -ne $allCases.Count) {
|
|
Throw-CaptureError 'uat_token_vault_contract_invalid'
|
|
}
|
|
$entryKeys = @{}
|
|
foreach ($entry in $vaultEntries) {
|
|
if (-not (Test-ExactProperties $entry @(
|
|
'workflow', 'caseCode', 'protectedTokenBase64'))) {
|
|
Throw-CaptureError 'uat_token_vault_contract_invalid'
|
|
}
|
|
$entryKey = [string]$entry.workflow + '|' +
|
|
[string]$entry.caseCode
|
|
if ($entryKeys.ContainsKey($entryKey)) {
|
|
Throw-CaptureError 'uat_token_vault_contract_invalid'
|
|
}
|
|
$entryKeys[$entryKey] = $true
|
|
}
|
|
$tokenEntries = @($vaultEntries | Where-Object {
|
|
[string]$_.workflow -ceq $workflowName -and
|
|
[string]$_.caseCode -ceq $CaptureCaseCode
|
|
})
|
|
if ($tokenEntries.Count -ne 1 -or
|
|
[string]$tokenEntries[0].protectedTokenBase64 -cnotmatch
|
|
'^[A-Za-z0-9+/]{64,4096}={0,2}$') {
|
|
Throw-CaptureError 'uat_token_vault_entry_invalid'
|
|
}
|
|
$protectedBytes = [Convert]::FromBase64String(
|
|
[string]$tokenEntries[0].protectedTokenBase64)
|
|
$plainBytes = $null
|
|
try {
|
|
$entropy = $utf8.GetBytes([string]$VerifiedData.authorizationId)
|
|
$plainBytes = [Security.Cryptography.ProtectedData]::Unprotect(
|
|
$protectedBytes,
|
|
$entropy,
|
|
[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
$token = $utf8.GetString($plainBytes)
|
|
}
|
|
finally {
|
|
if ($null -ne $plainBytes) {
|
|
[Array]::Clear($plainBytes, 0, $plainBytes.Length)
|
|
}
|
|
}
|
|
if ($token -cnotmatch '^[A-Za-z0-9_-]{32,128}$' -or
|
|
(Get-Sha256Hex ($utf8.GetBytes($token))) -cne
|
|
[string]$caseAuthorization.tokenSha256) {
|
|
Throw-CaptureError 'uat_token_invalid'
|
|
}
|
|
return [pscustomobject]@{
|
|
AuthorizationId = [string]$VerifiedData.authorizationId
|
|
AuthorizationIdSha256 = [string]$VerifiedData.authorizationIdSha256
|
|
AuthorizationSourceSha256 = [string]$VerifiedData.sourceSha256
|
|
AuthorizationContentSha256 = [string]$VerifiedData.contentSha256
|
|
AuthorizationIssuedAtUtc =
|
|
$issuedAtMatches[0].Groups['value'].Value
|
|
AuthorizationExpiresAtUtc =
|
|
$expiresAtMatches[0].Groups['value'].Value
|
|
CaseCode = $CaptureCaseCode
|
|
Workflow = $workflowName
|
|
TargetCommand = $TargetCommandName
|
|
PreparationCommand = $preparationCommand
|
|
AccountBook = [string]$signedScope.accountBook
|
|
SubSystemId = [string]$signedScope.subSystemId
|
|
UserId = [string]$signedScope.userId
|
|
UserName = [string]$signedScope.userName
|
|
DatabaseScopeFingerprint =
|
|
([string]$signedScope.databaseScopeFingerprint).ToLowerInvariant()
|
|
TokenSha256 = [string]$caseAuthorization.tokenSha256
|
|
Token = $token
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith(
|
|
'workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError 'uat_authorization_or_vault_invalid'
|
|
}
|
|
}
|
|
|
|
function Read-CliEnvelope([string]$Text, [int]$ExitCode) {
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($Text) -or
|
|
$Text.Length -gt $maximumResponseCharacters) {
|
|
Throw-CaptureError 'cli_response_invalid'
|
|
}
|
|
$document = $Text | ConvertFrom-Json
|
|
if ($null -eq $document -or
|
|
$null -eq $document.ok -or
|
|
$document.ok -isnot [bool] -or
|
|
([string]$document.correlationId) -notmatch '^[A-Za-z0-9_.:-]{8,128}$') {
|
|
Throw-CaptureError 'cli_response_invalid'
|
|
}
|
|
if ($document.ok) {
|
|
if ($ExitCode -ne 0 -or
|
|
-not (Test-ExactProperties $document @('ok', 'correlationId', 'data')) -or
|
|
$null -eq $document.data) {
|
|
Throw-CaptureError 'cli_response_invalid'
|
|
}
|
|
}
|
|
else {
|
|
if ($ExitCode -eq 0 -or
|
|
-not (Test-ExactProperties $document @('ok', 'correlationId', 'error')) -or
|
|
-not (Test-ExactProperties $document.error @('code', 'message', 'exitCode')) -or
|
|
([string]$document.error.code) -notmatch '^[a-z0-9_.-]{1,128}$' -or
|
|
[int]$document.error.exitCode -ne $ExitCode) {
|
|
Throw-CaptureError 'cli_response_invalid'
|
|
}
|
|
}
|
|
return $document
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError 'cli_response_invalid'
|
|
}
|
|
}
|
|
|
|
function Assert-TrustedVerifierCliUnchanged {
|
|
$actual = (Get-FileHash -LiteralPath $script:verifierCliFull `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actual -cne $script:expectedVerifierCliHash) {
|
|
Throw-CaptureError 'verifier_cli_hash_changed'
|
|
}
|
|
}
|
|
|
|
function Assert-TrustedRuntimeCliUnchanged {
|
|
$actual = (Get-FileHash -LiteralPath $script:runtimeCliFull `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actual -cne $script:expectedRuntimeCliHash) {
|
|
Throw-CaptureError 'runtime_cli_hash_changed'
|
|
}
|
|
}
|
|
|
|
function Assert-VerifierCliArguments([string[]]$Arguments) {
|
|
$verifyAuthorization = $Arguments.Count -ge 4 -and
|
|
$Arguments[0] -ceq 'acceptance' -and
|
|
$Arguments[1] -ceq 'verify-uat-authorization'
|
|
$projectObservation = $Arguments.Count -ge 6 -and
|
|
$Arguments[0] -ceq 'adapters' -and
|
|
$Arguments[1] -ceq 'project-write-observation-files'
|
|
if (-not $verifyAuthorization -and -not $projectObservation) {
|
|
Throw-CaptureError 'verifier_cli_command_denied'
|
|
}
|
|
}
|
|
|
|
function Assert-RuntimeCliArguments([string[]]$Arguments) {
|
|
$version = $Arguments.Count -eq 3 -and
|
|
$Arguments[0] -ceq 'version' -and
|
|
$Arguments[1] -ceq '--correlation-id'
|
|
$bridge = $Arguments.Count -ge 2 -and
|
|
$Arguments[0] -ceq 'bridge' -and
|
|
$Arguments[1] -cin @('health', 'context', 'plan', 'execute')
|
|
if (-not $version -and -not $bridge) {
|
|
Throw-CaptureError 'runtime_cli_command_denied'
|
|
}
|
|
}
|
|
|
|
function Invoke-TrustedCliProcess(
|
|
[string]$ExecutablePath,
|
|
[string]$ExpectedHash,
|
|
[string[]]$Arguments,
|
|
[string]$StandardInputValue,
|
|
[string]$Step
|
|
) {
|
|
$actualHash = (Get-FileHash -LiteralPath $ExecutablePath `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actualHash -cne $ExpectedHash) {
|
|
Throw-CaptureError ($Step + '_cli_hash_changed')
|
|
}
|
|
$process = New-Object Diagnostics.Process
|
|
try {
|
|
$start = New-Object Diagnostics.ProcessStartInfo
|
|
$start.FileName = $ExecutablePath
|
|
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($ExecutablePath)
|
|
$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-CaptureError ($Step + '_process_start_failed') }
|
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
|
if ($null -ne $StandardInputValue) {
|
|
$process.StandardInput.Write($StandardInputValue)
|
|
}
|
|
$process.StandardInput.Close()
|
|
$processTimeout = [Math]::Min(330000, $BridgeTimeoutMilliseconds + 30000)
|
|
if (-not $process.WaitForExit($processTimeout)) {
|
|
try { $process.Kill() } catch { }
|
|
Throw-CaptureError ($Step + '_timeout')
|
|
}
|
|
$process.WaitForExit()
|
|
$stdout = $stdoutTask.Result
|
|
$stderr = $stderrTask.Result
|
|
if (($process.ExitCode -eq 0 -and
|
|
([string]::IsNullOrWhiteSpace($stdout) -or
|
|
-not [string]::IsNullOrWhiteSpace($stderr))) -or
|
|
($process.ExitCode -ne 0 -and
|
|
([string]::IsNullOrWhiteSpace($stderr) -or
|
|
-not [string]::IsNullOrWhiteSpace($stdout)))) {
|
|
Throw-CaptureError ($Step + '_stream_contract_invalid')
|
|
}
|
|
$raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr }
|
|
$envelope = Read-CliEnvelope $raw $process.ExitCode
|
|
$hashAfter = (Get-FileHash -LiteralPath $ExecutablePath `
|
|
-Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($hashAfter -cne $ExpectedHash) {
|
|
Throw-CaptureError ($Step + '_cli_hash_changed')
|
|
}
|
|
return [PSCustomObject]@{
|
|
ExitCode = $process.ExitCode
|
|
Raw = $raw
|
|
Envelope = $envelope
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError ($Step + '_process_failed')
|
|
}
|
|
finally {
|
|
if ($null -ne $process) { $process.Dispose() }
|
|
}
|
|
}
|
|
|
|
function Invoke-TrustedVerifierCli(
|
|
[string[]]$Arguments,
|
|
[string]$StandardInputValue,
|
|
[string]$Step
|
|
) {
|
|
Assert-VerifierCliArguments $Arguments
|
|
return Invoke-TrustedCliProcess `
|
|
$script:verifierCliFull `
|
|
$script:expectedVerifierCliHash `
|
|
$Arguments `
|
|
$StandardInputValue `
|
|
$Step
|
|
}
|
|
|
|
function Invoke-TrustedRuntimeCli(
|
|
[string[]]$Arguments,
|
|
[string]$StandardInputValue,
|
|
[string]$Step
|
|
) {
|
|
Assert-RuntimeCliArguments $Arguments
|
|
return Invoke-TrustedCliProcess `
|
|
$script:runtimeCliFull `
|
|
$script:expectedRuntimeCliHash `
|
|
$Arguments `
|
|
$StandardInputValue `
|
|
$Step
|
|
}
|
|
|
|
function Read-StrictIdempotencyKeyFile([string]$Path) {
|
|
$full = Resolve-RegularFile $Path 512 'idempotency_key_file_invalid'
|
|
try {
|
|
$value = [IO.File]::ReadAllText($full, $utf8)
|
|
if ($value.EndsWith("`r`n", [StringComparison]::Ordinal)) {
|
|
$value = $value.Substring(0, $value.Length - 2)
|
|
}
|
|
elseif ($value.EndsWith("`n", [StringComparison]::Ordinal)) {
|
|
$value = $value.Substring(0, $value.Length - 1)
|
|
}
|
|
if ($value.Contains("`r") -or $value.Contains("`n")) {
|
|
Throw-CaptureError 'idempotency_key_file_invalid'
|
|
}
|
|
return $value
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
|
|
Throw-CaptureError 'idempotency_key_file_invalid'
|
|
}
|
|
}
|
|
|
|
function Convert-SecureStringToPlainText([Security.SecureString]$Value) {
|
|
$pointer = [IntPtr]::Zero
|
|
try {
|
|
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value)
|
|
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
|
|
}
|
|
finally {
|
|
if ($pointer -ne [IntPtr]::Zero) {
|
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
|
|
}
|
|
}
|
|
}
|
|
|
|
function Resolve-IntegerObservation(
|
|
[int]$Current,
|
|
[int]$Minimum,
|
|
[int]$Maximum,
|
|
[string]$Prompt,
|
|
[string]$Code
|
|
) {
|
|
if ($Current -ge $Minimum -and $Current -le $Maximum) { return $Current }
|
|
if ($NonInteractive) { Throw-CaptureError $Code }
|
|
$raw = Read-Host $Prompt
|
|
$parsed = 0
|
|
if (-not [int]::TryParse($raw, [ref]$parsed) -or
|
|
$parsed -lt $Minimum -or $parsed -gt $Maximum) {
|
|
Throw-CaptureError $Code
|
|
}
|
|
return $parsed
|
|
}
|
|
|
|
function Resolve-BooleanObservation(
|
|
[Nullable[bool]]$Current,
|
|
[string]$Prompt,
|
|
[string]$Code
|
|
) {
|
|
if ($null -ne $Current) { return [bool]$Current }
|
|
if ($NonInteractive) { Throw-CaptureError $Code }
|
|
$raw = (Read-Host ($Prompt + ' [true/false]')).Trim().ToLowerInvariant()
|
|
if ($raw -eq 'true') { return $true }
|
|
if ($raw -eq 'false') { return $false }
|
|
Throw-CaptureError $Code
|
|
}
|
|
|
|
function Write-StrictJson([object]$Value, [string]$Path) {
|
|
$json = $Value | ConvertTo-Json -Depth 20
|
|
[IO.File]::WriteAllText($Path, $json, $utf8)
|
|
}
|
|
|
|
function New-StagedOutput([string]$FinalPath) {
|
|
$directory = [IO.Path]::GetDirectoryName($FinalPath)
|
|
$path = Join-Path $directory ('capture-' + [Guid]::NewGuid().ToString('N') + '.json')
|
|
if ([IO.File]::Exists($path)) { Throw-CaptureError 'staged_output_collision' }
|
|
$stagedOutputs.Add($path)
|
|
return $path
|
|
}
|
|
|
|
function Invoke-Projection([string]$IndexPath, [string]$StagePath, [string]$Step) {
|
|
$result = Invoke-TrustedVerifierCli @(
|
|
'adapters', 'project-write-observation-files',
|
|
'--input', $IndexPath,
|
|
'--output', $StagePath,
|
|
'--correlation-id', ('projection-' + [Guid]::NewGuid().ToString('N'))
|
|
) $null $Step
|
|
if ($result.ExitCode -ne 0) {
|
|
Throw-CaptureError ($Step + '_' + [string]$result.Envelope.error.code)
|
|
}
|
|
if (-not [IO.File]::Exists($StagePath)) {
|
|
Throw-CaptureError ($Step + '_output_missing')
|
|
}
|
|
}
|
|
|
|
try {
|
|
$verifierCliFull = Resolve-RegularFile `
|
|
$VerifierCliPath 128MB 'verifier_cli_file_invalid'
|
|
$runtimeCliFull = Resolve-RegularFile `
|
|
$RuntimeCliPath 128MB 'runtime_cli_file_invalid'
|
|
if ([IO.Path]::GetFileName($verifierCliFull) -ine 'lserp-cli.exe') {
|
|
Throw-CaptureError 'verifier_cli_filename_invalid'
|
|
}
|
|
if ([IO.Path]::GetFileName($runtimeCliFull) -ine 'lserp-agent-cli.exe') {
|
|
Throw-CaptureError 'runtime_cli_filename_invalid'
|
|
}
|
|
if ($verifierCliFull -ieq $runtimeCliFull) {
|
|
Throw-CaptureError 'cli_role_path_conflict'
|
|
}
|
|
$verifierCliLock = [IO.File]::Open(
|
|
$verifierCliFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$runtimeCliLock = [IO.File]::Open(
|
|
$runtimeCliFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$expectedVerifierCliHash = $ExpectedVerifierCliSha256.ToLowerInvariant()
|
|
$expectedRuntimeCliHash = $ExpectedRuntimeCliSha256.ToLowerInvariant()
|
|
$expectedVerifierCliSigner =
|
|
$ExpectedVerifierSignerThumbprint.ToUpperInvariant()
|
|
$expectedRuntimeCliSigner =
|
|
$ExpectedRuntimeSignerThumbprint.ToUpperInvariant()
|
|
Assert-TrustedVerifierCliUnchanged
|
|
Assert-TrustedRuntimeCliUnchanged
|
|
|
|
$verifierSignature = Get-AuthenticodeSignature `
|
|
-LiteralPath $verifierCliFull
|
|
$actualVerifierSigner = if ($null -eq $verifierSignature.SignerCertificate) {
|
|
''
|
|
} else {
|
|
([string]$verifierSignature.SignerCertificate.Thumbprint).
|
|
Replace(' ', '').ToUpperInvariant()
|
|
}
|
|
if ($verifierSignature.Status -ne
|
|
[System.Management.Automation.SignatureStatus]::Valid -or
|
|
$actualVerifierSigner -cne $expectedVerifierCliSigner) {
|
|
Throw-CaptureError 'verifier_cli_signature_invalid'
|
|
}
|
|
$runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCliFull
|
|
$actualRuntimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) {
|
|
''
|
|
} else {
|
|
([string]$runtimeSignature.SignerCertificate.Thumbprint).
|
|
Replace(' ', '').ToUpperInvariant()
|
|
}
|
|
if ($runtimeSignature.Status -ne
|
|
[System.Management.Automation.SignatureStatus]::Valid -or
|
|
$actualRuntimeSigner -cne $expectedRuntimeCliSigner) {
|
|
Throw-CaptureError 'runtime_cli_signature_invalid'
|
|
}
|
|
|
|
$runtimeIdentityCorrelation =
|
|
'uat-runtime-' + [Guid]::NewGuid().ToString('N')
|
|
$runtimeIdentityResponse = Invoke-TrustedRuntimeCli @(
|
|
'version', '--correlation-id', $runtimeIdentityCorrelation
|
|
) $null 'runtime_cli_identity'
|
|
$runtimeIdentity = $runtimeIdentityResponse.Envelope.data
|
|
if ($runtimeIdentityResponse.ExitCode -ne 0 -or
|
|
-not $runtimeIdentityResponse.Envelope.ok -or
|
|
[string]$runtimeIdentityResponse.Envelope.correlationId -cne
|
|
$runtimeIdentityCorrelation -or
|
|
-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 -isnot [bool] -or
|
|
$runtimeIdentity.bridgeOnly -ne $true -or
|
|
$runtimeIdentity.databaseDirectAccess -isnot [bool] -or
|
|
$runtimeIdentity.databaseDirectAccess -ne $false -or
|
|
[string]$runtimeIdentity.sessionSource -cne
|
|
'current_logged_in_erp_process') {
|
|
Throw-CaptureError 'runtime_cli_identity_invalid'
|
|
}
|
|
|
|
$inputFull = Resolve-RegularFile $CommandInputFile (512KB) 'command_input_invalid'
|
|
$uatAuthorizationFull = Resolve-RegularFile `
|
|
$UatAuthorizationFile (512KB) 'uat_authorization_file_invalid'
|
|
$uatTokenVaultFull = Resolve-RegularFile `
|
|
$UatTokenVaultPath (512KB) 'uat_token_vault_file_invalid'
|
|
if ($inputFull -ieq $uatAuthorizationFull -or
|
|
$inputFull -ieq $uatTokenVaultFull -or
|
|
$uatAuthorizationFull -ieq $uatTokenVaultFull -or
|
|
$verifierCliFull -ieq $inputFull -or
|
|
$runtimeCliFull -ieq $inputFull -or
|
|
$verifierCliFull -ieq $uatAuthorizationFull -or
|
|
$verifierCliFull -ieq $uatTokenVaultFull -or
|
|
$runtimeCliFull -ieq $uatAuthorizationFull -or
|
|
$runtimeCliFull -ieq $uatTokenVaultFull) {
|
|
Throw-CaptureError 'trusted_input_path_conflict'
|
|
}
|
|
$uatAuthorizationLock = [IO.File]::Open(
|
|
$uatAuthorizationFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$uatTokenVaultLock = [IO.File]::Open(
|
|
$uatTokenVaultFull,
|
|
[IO.FileMode]::Open,
|
|
[IO.FileAccess]::Read,
|
|
[IO.FileShare]::Read)
|
|
$actualAuthorizationHash = (Get-FileHash `
|
|
-LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($actualAuthorizationHash -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant()) {
|
|
Throw-CaptureError 'uat_authorization_hash_mismatch'
|
|
}
|
|
$uatVaultSha256 = (Get-FileHash `
|
|
-LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$uatVerification = Invoke-TrustedVerifierCli @(
|
|
'acceptance', 'verify-uat-authorization',
|
|
'--input', $uatAuthorizationFull,
|
|
'--correlation-id', ('uat-auth-' + [Guid]::NewGuid().ToString('N'))
|
|
) $null 'verify_uat_authorization'
|
|
if ($uatVerification.ExitCode -ne 0 -or
|
|
-not $uatVerification.Envelope.ok) {
|
|
Throw-CaptureError 'uat_authorization_verification_failed'
|
|
}
|
|
$uatGrant = Read-VerifiedUatGrant `
|
|
$uatVerification.Envelope.data `
|
|
$uatAuthorizationFull `
|
|
$uatTokenVaultFull `
|
|
$CaseCode `
|
|
$CommandName
|
|
$uatToken = [string]$uatGrant.Token
|
|
|
|
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
|
|
$auditOutputFull = $null
|
|
$auditCaseCode = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($CorrelatedAuditOutputPath)) {
|
|
if ($CaseCode -eq 'purchase_unique_match_commit') {
|
|
$auditCaseCode = 'purchase_audit_correlated'
|
|
}
|
|
elseif ($CaseCode -eq 'leave_create_draft_commit') {
|
|
$auditCaseCode = 'leave_audit_correlated'
|
|
}
|
|
else {
|
|
Throw-CaptureError 'correlated_audit_case_invalid'
|
|
}
|
|
$auditOutputFull = Resolve-NewJsonPath `
|
|
$CorrelatedAuditOutputPath 'correlated_audit_output_invalid'
|
|
if ($auditOutputFull -ieq $outputFull) {
|
|
Throw-CaptureError 'correlated_audit_output_invalid'
|
|
}
|
|
$uatAuditGrant = Read-VerifiedUatGrant `
|
|
$uatVerification.Envelope.data `
|
|
$uatAuthorizationFull `
|
|
$uatTokenVaultFull `
|
|
$auditCaseCode `
|
|
$CommandName
|
|
$uatAuditTokenSha256 = [string]$uatAuditGrant.TokenSha256
|
|
$uatAuditGrant.Token = $null
|
|
$uatAuditGrant = $null
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($RestrictedWorkingRoot)) {
|
|
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
|
Throw-CaptureError 'restricted_working_root_required'
|
|
}
|
|
$RestrictedWorkingRoot = Join-Path `
|
|
$env:LOCALAPPDATA 'Langsu\Lserp\AcceptanceRaw'
|
|
}
|
|
$rawDirectory = New-RestrictedDirectory $RestrictedWorkingRoot
|
|
$sourceInputCopy = Join-Path $rawDirectory 'source-command-input.json'
|
|
$inputCopy = Join-Path $rawDirectory 'command-input.json'
|
|
$preparationResponsePath = Join-Path `
|
|
$rawDirectory 'preparation-response.json'
|
|
$contextResponsePath = Join-Path $rawDirectory 'context-response.json'
|
|
$planResponsePath = Join-Path $rawDirectory 'plan-response.json'
|
|
$executeResponsePath = Join-Path $rawDirectory 'execute-response.json'
|
|
$indexPath = Join-Path $rawDirectory 'case-index.json'
|
|
[IO.File]::Copy($inputFull, $sourceInputCopy, $false)
|
|
|
|
$postPlanStagingCases = @(
|
|
'purchase_runtime_recheck_blocked',
|
|
'leave_stale_flow_type_blocked',
|
|
'leave_runtime_recheck_blocked'
|
|
)
|
|
$requiresPostPlanStaging = $CaseCode -in $postPlanStagingCases
|
|
if ($requiresPostPlanStaging -and
|
|
(-not $Execute -or -not $PauseAfterPlanForOperatorStaging.IsPresent)) {
|
|
Throw-CaptureError 'post_plan_operator_staging_required'
|
|
}
|
|
if ($PauseAfterPlanForOperatorStaging.IsPresent -and
|
|
(-not $Execute -or $NonInteractive -or -not $requiresPostPlanStaging)) {
|
|
Throw-CaptureError 'post_plan_operator_staging_mode_invalid'
|
|
}
|
|
|
|
$hasSecureKey = $null -ne $IdempotencyKey
|
|
$hasKeyFile = -not [string]::IsNullOrWhiteSpace($IdempotencyKeyFile)
|
|
if ($hasSecureKey -and $hasKeyFile) {
|
|
Throw-CaptureError 'idempotency_key_source_conflict'
|
|
}
|
|
if (-not $Execute -and ($hasSecureKey -or $hasKeyFile)) {
|
|
Throw-CaptureError 'idempotency_key_without_execute'
|
|
}
|
|
if ($Execute) {
|
|
if ($hasKeyFile) {
|
|
$idempotencyPlain = Read-StrictIdempotencyKeyFile $IdempotencyKeyFile
|
|
}
|
|
else {
|
|
if (-not $hasSecureKey) {
|
|
if ($NonInteractive) {
|
|
Throw-CaptureError 'idempotency_key_required'
|
|
}
|
|
$IdempotencyKey = Read-Host `
|
|
'输入本次稳定业务幂等键(不会显示)' -AsSecureString
|
|
}
|
|
$idempotencyPlain = Convert-SecureStringToPlainText $IdempotencyKey
|
|
}
|
|
if ($idempotencyPlain -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') {
|
|
Throw-CaptureError 'idempotency_key_invalid'
|
|
}
|
|
}
|
|
|
|
$healthCorrelationId = 'uat-health-' + [Guid]::NewGuid().ToString('N')
|
|
$health = Invoke-TrustedRuntimeCli @(
|
|
'bridge', 'health',
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
[string]$uatGrant.DatabaseScopeFingerprint,
|
|
'--expected-user-id', [string]$uatGrant.UserId,
|
|
'--expected-user-name', [string]$uatGrant.UserName,
|
|
'--expected-account-book', [string]$uatGrant.AccountBook,
|
|
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', $healthCorrelationId
|
|
) $null 'bridge_uat_health'
|
|
$healthUat = if ($null -eq $health.Envelope.data) {
|
|
$null
|
|
} else {
|
|
$health.Envelope.data.workflowUat
|
|
}
|
|
if ($health.ExitCode -ne 0 -or -not $health.Envelope.ok -or
|
|
$null -eq $healthUat -or $healthUat.enabled -ne $true -or
|
|
[string]$healthUat.sourceSha256 -cne
|
|
[string]$uatGrant.AuthorizationSourceSha256 -or
|
|
[string]$healthUat.authorizationIdSha256 -cne
|
|
[string]$uatGrant.AuthorizationIdSha256 -or
|
|
$healthUat.generalCapabilitiesHidden -ne $true) {
|
|
Throw-CaptureError 'bridge_uat_authorization_mismatch'
|
|
}
|
|
|
|
$correlationId = 'uat-' + [Guid]::NewGuid().ToString('N')
|
|
$context = Invoke-TrustedRuntimeCli @(
|
|
'bridge', 'context',
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
[string]$uatGrant.DatabaseScopeFingerprint,
|
|
'--expected-user-id', [string]$uatGrant.UserId,
|
|
'--expected-user-name', [string]$uatGrant.UserName,
|
|
'--expected-account-book', [string]$uatGrant.AccountBook,
|
|
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', $correlationId
|
|
) $null 'bridge_context'
|
|
if ($context.ExitCode -ne 0 -or -not $context.Envelope.ok) {
|
|
Throw-CaptureError 'bridge_context_unavailable'
|
|
}
|
|
[IO.File]::WriteAllText($contextResponsePath, $context.Raw, $utf8)
|
|
|
|
if ($null -ne $uatGrant.PreparationCommand) {
|
|
$preparation = Invoke-TrustedRuntimeCli @(
|
|
'bridge', 'plan', [string]$uatGrant.PreparationCommand,
|
|
'--input', $sourceInputCopy,
|
|
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
|
|
'--uat-case-code', $CaseCode,
|
|
'--uat-token-stdin',
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
[string]$uatGrant.DatabaseScopeFingerprint,
|
|
'--expected-user-id', [string]$uatGrant.UserId,
|
|
'--expected-user-name', [string]$uatGrant.UserName,
|
|
'--expected-account-book', [string]$uatGrant.AccountBook,
|
|
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', $correlationId
|
|
) $uatToken 'bridge_preparation_plan'
|
|
[IO.File]::WriteAllText(
|
|
$preparationResponsePath, $preparation.Raw, $utf8)
|
|
$resolvedInput = if ($null -eq $preparation.Envelope.data -or
|
|
$null -eq $preparation.Envelope.data.plan -or
|
|
$null -eq $preparation.Envelope.data.plan.data) {
|
|
$null
|
|
} else {
|
|
$preparation.Envelope.data.plan.data.resolvedInput
|
|
}
|
|
if ($preparation.ExitCode -ne 0 -or
|
|
-not $preparation.Envelope.ok -or
|
|
$preparation.Envelope.data.plan.valid -ne $true -or
|
|
$preparation.Envelope.data.plan.executionAllowed -ne $false -or
|
|
[string]$preparation.Envelope.data.plan.data.resolvedCommand -cne
|
|
$CommandName -or
|
|
$preparation.Envelope.data.plan.data.requiresFollowupPlan -ne $true -or
|
|
$null -eq $resolvedInput -or
|
|
$resolvedInput -isnot [pscustomobject] -or
|
|
[string]$resolvedInput.resolutionProof -cnotmatch
|
|
'^(?:rp1|lrp1)\.[0-9]{1,19}\.[a-f0-9]{32}\.[a-f0-9]{64}\.[A-Za-z0-9_-]{40,64}$') {
|
|
Throw-CaptureError 'bridge_preparation_not_resolved'
|
|
}
|
|
Write-StrictJson $resolvedInput $inputCopy
|
|
}
|
|
else {
|
|
[IO.File]::Copy($sourceInputCopy, $inputCopy, $false)
|
|
}
|
|
|
|
$plan = Invoke-TrustedRuntimeCli @(
|
|
'bridge', 'plan', $CommandName,
|
|
'--input', $inputCopy,
|
|
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
|
|
'--uat-case-code', $CaseCode,
|
|
'--uat-token-stdin',
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
[string]$uatGrant.DatabaseScopeFingerprint,
|
|
'--expected-user-id', [string]$uatGrant.UserId,
|
|
'--expected-user-name', [string]$uatGrant.UserName,
|
|
'--expected-account-book', [string]$uatGrant.AccountBook,
|
|
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', $correlationId
|
|
) $uatToken 'bridge_plan'
|
|
[IO.File]::WriteAllText($planResponsePath, $plan.Raw, $utf8)
|
|
|
|
$executeResponseFileName = $null
|
|
if ($Execute) {
|
|
if ($plan.ExitCode -ne 0 -or -not $plan.Envelope.ok -or
|
|
$null -eq $plan.Envelope.data.plan -or
|
|
$plan.Envelope.data.plan.valid -ne $true -or
|
|
$plan.Envelope.data.plan.executionAllowed -ne $true -or
|
|
([string]$plan.Envelope.data.plan.planId) -notmatch '^[A-Fa-f0-9]{32}$') {
|
|
Throw-CaptureError 'bridge_plan_not_executable'
|
|
}
|
|
if ($PauseAfterPlanForOperatorStaging.IsPresent) {
|
|
$stagingConfirmation = 'STAGED:' + $CaseCode
|
|
$entered = Read-Host (
|
|
'计划已固定;请由授权 DBA/配置人员完成计划后场景变更,' +
|
|
'保持当前 ERP 进程运行,然后输入 ' + $stagingConfirmation)
|
|
if ([string]$entered -cne $stagingConfirmation) {
|
|
Throw-CaptureError 'post_plan_operator_staging_not_confirmed'
|
|
}
|
|
Assert-TrustedVerifierCliUnchanged
|
|
Assert-TrustedRuntimeCliUnchanged
|
|
$authorizationHashAfterStaging = (Get-FileHash `
|
|
-LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
$vaultHashAfterStaging = (Get-FileHash `
|
|
-LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
if ($authorizationHashAfterStaging -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
|
|
$vaultHashAfterStaging -cne $uatVaultSha256) {
|
|
Throw-CaptureError 'trusted_input_changed_during_operator_staging'
|
|
}
|
|
}
|
|
$execute = Invoke-TrustedRuntimeCli @(
|
|
'bridge', 'execute', [string]$plan.Envelope.data.plan.planId,
|
|
'--idempotency-key-stdin',
|
|
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
|
|
'--uat-case-code', $CaseCode,
|
|
'--uat-token-stdin',
|
|
'--erp-process-id', [string]$ErpProcessId,
|
|
'--expected-database-scope-fingerprint',
|
|
[string]$uatGrant.DatabaseScopeFingerprint,
|
|
'--expected-user-id', [string]$uatGrant.UserId,
|
|
'--expected-user-name', [string]$uatGrant.UserName,
|
|
'--expected-account-book', [string]$uatGrant.AccountBook,
|
|
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
|
|
'--expected-is-administrator', 'false',
|
|
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
|
|
'--correlation-id', $correlationId
|
|
) ($uatToken + "`n" + $idempotencyPlain) 'bridge_execute'
|
|
[IO.File]::WriteAllText($executeResponsePath, $execute.Raw, $utf8)
|
|
$executeResponseFileName = 'execute-response.json'
|
|
}
|
|
|
|
$mutationCount = Resolve-IntegerObservation `
|
|
$BusinessMutationCount 0 1000 `
|
|
'输入 DBA 只读复核得到的业务变更行数' `
|
|
'business_mutation_count_required'
|
|
if ($Execute) {
|
|
$confirmationObserved = Resolve-BooleanObservation `
|
|
$NativeConfirmationObserved `
|
|
'是否亲眼看到并处理了 ERP 原生确认窗口' `
|
|
'native_confirmation_observation_required'
|
|
}
|
|
else {
|
|
if ($null -ne $NativeConfirmationObserved -and
|
|
[bool]$NativeConfirmationObserved) {
|
|
Throw-CaptureError 'plan_only_confirmation_invalid'
|
|
}
|
|
$confirmationObserved = $false
|
|
}
|
|
$auditCount = Resolve-IntegerObservation `
|
|
$AuditEventCount 1 1000 `
|
|
'输入只读复核得到的命令审计事件数' `
|
|
'audit_event_count_required'
|
|
$sourcePayloadBound = Resolve-BooleanObservation `
|
|
$SourceDocumentWritePayloadBound `
|
|
'来源附件集合是否已在写过程 payload 中核对一致' `
|
|
'source_payload_observation_required'
|
|
$sourceAuditCount = Resolve-IntegerObservation `
|
|
$SourceDocumentAuditCount 0 1000 `
|
|
'输入同一业务审计号下去重后的来源附件审计行数' `
|
|
'source_audit_count_required'
|
|
|
|
$observedAtUtc = [DateTimeOffset]::UtcNow.ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
|
[Globalization.CultureInfo]::InvariantCulture)
|
|
$index = [ordered]@{
|
|
schemaVersion = '1.3'
|
|
caseCode = $CaseCode
|
|
uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256
|
|
uatAuthorizationContentSha256 = [string]$uatGrant.AuthorizationContentSha256
|
|
uatAuthorizationIdSha256 = [string]$uatGrant.AuthorizationIdSha256
|
|
uatAuthorizationIssuedAtUtc = [string]$uatGrant.AuthorizationIssuedAtUtc
|
|
uatAuthorizationExpiresAtUtc = [string]$uatGrant.AuthorizationExpiresAtUtc
|
|
uatExecutionCaseCode = [string]$uatGrant.CaseCode
|
|
uatTokenSha256 = [string]$uatGrant.TokenSha256
|
|
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
|
runtimeCliSha256 = $expectedRuntimeCliHash
|
|
runtimeCliSignerThumbprint = $expectedRuntimeCliSigner
|
|
commandName = $CommandName
|
|
commandInputFile = 'command-input.json'
|
|
contextCliResponseFile = 'context-response.json'
|
|
planCliResponseFile = 'plan-response.json'
|
|
executeCliResponseFile = $executeResponseFileName
|
|
idempotencyKey = $idempotencyPlain
|
|
businessMutationCount = $mutationCount
|
|
nativeConfirmationObserved = $confirmationObserved
|
|
auditEventCount = $auditCount
|
|
sourceDocumentWritePayloadBound = $sourcePayloadBound
|
|
sourceDocumentAuditCount = $sourceAuditCount
|
|
observedAtUtc = $observedAtUtc
|
|
}
|
|
Write-StrictJson $index $indexPath
|
|
|
|
$mainStage = New-StagedOutput $outputFull
|
|
Invoke-Projection $indexPath $mainStage 'project_main_case'
|
|
$auditStage = $null
|
|
if ($null -ne $auditCaseCode) {
|
|
$index.caseCode = $auditCaseCode
|
|
$index.uatTokenSha256 = $uatAuditTokenSha256
|
|
$index.businessMutationCount = 0
|
|
Write-StrictJson $index $indexPath
|
|
$auditStage = New-StagedOutput $auditOutputFull
|
|
Invoke-Projection $indexPath $auditStage 'project_correlated_audit_case'
|
|
}
|
|
|
|
[IO.File]::Move($mainStage, $outputFull)
|
|
$publishedOutputs.Add($outputFull)
|
|
if ($null -ne $auditStage) {
|
|
[IO.File]::Move($auditStage, $auditOutputFull)
|
|
$publishedOutputs.Add($auditOutputFull)
|
|
}
|
|
Assert-TrustedVerifierCliUnchanged
|
|
Assert-TrustedRuntimeCliUnchanged
|
|
if ((Get-FileHash -LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
|
|
(Get-FileHash -LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne
|
|
$uatVaultSha256) {
|
|
Throw-CaptureError 'uat_locked_input_changed'
|
|
}
|
|
|
|
$summaryJson = ([ordered]@{
|
|
ok = $true
|
|
packageType = 'workflow_write_case_capture'
|
|
schemaVersion = '1.2'
|
|
caseCode = $CaseCode
|
|
outputFile = [IO.Path]::GetFileName($outputFull)
|
|
correlatedAuditCaseCode = $auditCaseCode
|
|
correlatedAuditOutputFile = if ($null -eq $auditOutputFull) {
|
|
$null
|
|
} else {
|
|
[IO.Path]::GetFileName($auditOutputFull)
|
|
}
|
|
rawIdentifiersEmitted = $false
|
|
rawTemporaryFilesRetained = $false
|
|
uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256
|
|
verifierCli = [ordered]@{
|
|
fileName = 'lserp-cli.exe'
|
|
sha256 = $expectedVerifierCliHash
|
|
signerThumbprint = $expectedVerifierCliSigner
|
|
}
|
|
runtimeCli = [ordered]@{
|
|
fileName = 'lserp-agent-cli.exe'
|
|
version = $ExpectedRuntimeCliVersion
|
|
sha256 = $expectedRuntimeCliHash
|
|
signerThumbprint = $expectedRuntimeCliSigner
|
|
bridgeOnly = $true
|
|
databaseDirectAccess = $false
|
|
sessionSource = 'current_logged_in_erp_process'
|
|
}
|
|
uatCaseTokenEmitted = $false
|
|
registrationReady = $false
|
|
} | ConvertTo-Json -Depth 4)
|
|
}
|
|
catch {
|
|
foreach ($published in @($publishedOutputs)) {
|
|
try {
|
|
if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) }
|
|
}
|
|
catch { }
|
|
}
|
|
throw
|
|
}
|
|
finally {
|
|
$idempotencyPlain = $null
|
|
$uatToken = $null
|
|
$uatAuditTokenSha256 = $null
|
|
$cleanupFailed = $false
|
|
if ($null -ne $verifierCliLock) {
|
|
try { $verifierCliLock.Dispose() }
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
if ($null -ne $runtimeCliLock) {
|
|
try { $runtimeCliLock.Dispose() }
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
if ($null -ne $uatAuthorizationLock) {
|
|
try { $uatAuthorizationLock.Dispose() }
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
if ($null -ne $uatTokenVaultLock) {
|
|
try { $uatTokenVaultLock.Dispose() }
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
foreach ($staged in @($stagedOutputs)) {
|
|
try {
|
|
if ([IO.File]::Exists($staged)) { [IO.File]::Delete($staged) }
|
|
}
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
if ($null -ne $rawDirectory -and [IO.Directory]::Exists($rawDirectory)) {
|
|
try {
|
|
[IO.Directory]::Delete($rawDirectory, $true)
|
|
}
|
|
catch { $cleanupFailed = $true }
|
|
}
|
|
if ($cleanupFailed) {
|
|
foreach ($published in @($publishedOutputs)) {
|
|
try {
|
|
if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) }
|
|
}
|
|
catch { }
|
|
}
|
|
throw 'workflow_write_case_capture_failed:cleanup_failed'
|
|
}
|
|
}
|
|
|
|
Write-Output $summaryJson
|