[CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('purchase', 'leave', 'both')] [string]$Workflow, [ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')] [string]$PurchaseModuleCode = '', [ValidatePattern('^[A-Za-z0-9_.:-]{1,64}$')] [string]$LeaveModuleCode = '', [ValidatePattern('^[a-z0-9_.-]{1,128}$')] [string]$PurchaseAdapterId = '', [ValidatePattern('^[a-z0-9_.-]{1,128}$')] [string]$LeaveAdapterId = '', [ValidatePattern('^[a-z0-9_.-]{1,128}$')] [string]$PurchaseAdapterVersion = '', [ValidatePattern('^[a-z0-9_.-]{1,128}$')] [string]$LeaveAdapterVersion = '', [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$')] [string]$AuthorizationId, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$')] [string]$CustomerId, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')] [string]$EnvironmentId, [Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$AccountBook, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')] [string]$SubSystemId, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$')] [string]$ErpUserId, [Parameter(Mandatory = $true)] [ValidateLength(1, 128)] [string]$ErpUserName, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$DatabaseScopeFingerprint, [Parameter(Mandatory = $true)][string]$RuntimeConfigurationFile, [Parameter(Mandatory = $true)][string]$CustomerProfileFile, [Parameter(Mandatory = $true)][string]$RolloutPolicyFile, [Parameter(Mandatory = $true)][string]$CommercialPackageFile, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$SourceCommit, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{64}$')] [string]$ExpectedPackageSha256, [Parameter(Mandatory = $true)][string]$ErpExecutablePath, [Parameter(Mandatory = $true)][string]$RuntimeCliPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')] [string]$ExpectedRuntimeCliVersion, [Parameter(Mandatory = $true)][string]$VerifierCliPath, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedErpSignerThumbprint, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedCliSignerThumbprint, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string]$ExpectedRuntimeCliSignerThumbprint, [Parameter(Mandatory = $true)][ValidateLength(1, 128)][string]$ApprovedBy, [Parameter(Mandatory = $true)][switch]$DatabaseBackupVerified, [Parameter(Mandatory = $true)][switch]$RestoreProcedureVerified, [Parameter(Mandatory = $true)][switch]$NonProductionEnvironmentVerified, [Parameter(Mandatory = $true)][switch]$NativeConfirmationVerified, [Parameter(Mandatory = $true)][switch]$TransactionAuditVerified, [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Fa-f0-9 ]{40,59}$')] [string]$CertificateThumbprint, [Parameter(Mandatory = $true)][string]$OutputPath, [Parameter(Mandatory = $true)][string]$TokenVaultPath, [ValidateRange(1, 24)][int]$ValidHours = 8 ) 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_authorization_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_authorization_failed:elevated_operator_required' } $utf8 = [Text.UTF8Encoding]::new($false, $true) $locks = New-Object System.Collections.Generic.List[IO.FileStream] $published = New-Object System.Collections.Generic.List[string] $token = $null $tokenHash = $null $plainTokenBytes = $null function Throw-UatError([string]$Code) { throw ('workflow_uat_authorization_failed:' + $Code) } 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-UatError $Code } $current = $current.Parent } } catch { if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw } Throw-UatError $Code } } function Open-LockedInput( [string]$Path, [long]$MaximumBytes, [string]$ExpectedFileName, [string]$Code ) { try { $full = [IO.Path]::GetFullPath($Path) if (-not [IO.File]::Exists($full)) { Throw-UatError $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-UatError $Code } Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code $stream = [IO.File]::Open( $full, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) if ($stream.Length -le 0 -or $stream.Length -gt $MaximumBytes) { $stream.Dispose() Throw-UatError $Code } $script:locks.Add($stream) return [pscustomobject]@{ Path = $full; Stream = $stream } } catch { if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw } Throw-UatError $Code } } function Resolve-NewPath([string]$Path, [string]$Extension, [string]$Code) { try { $full = [IO.Path]::GetFullPath($Path) if ([IO.Path]::GetExtension($full) -ine $Extension -or [IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) { Throw-UatError $Code } Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code return $full } catch { if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw } Throw-UatError $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 $hash = ([BitConverter]::ToString($sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant() $Stream.Position = 0 return $hash } finally { $sha.Dispose() } } function Get-LockedUtf8Text([IO.FileStream]$Stream, [string]$Code) { $bytes = $null try { if ($Stream.Length -gt [int]::MaxValue) { Throw-UatError $Code } $bytes = New-Object byte[] ([int]$Stream.Length) $Stream.Position = 0 $offset = 0 while ($offset -lt $bytes.Length) { $read = $Stream.Read($bytes, $offset, $bytes.Length - $offset) if ($read -le 0) { Throw-UatError $Code } $offset += $read } $Stream.Position = 0 return $utf8.GetString($bytes) } catch { $Stream.Position = 0 if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw } Throw-UatError $Code } finally { if ($null -ne $bytes) { [Array]::Clear($bytes, 0, $bytes.Length) } } } function Assert-Authenticode( [string]$Path, [string]$ExpectedThumbprint, [string]$Code ) { $signature = Get-AuthenticodeSignature -LiteralPath $Path $actual = if ($null -eq $signature.SignerCertificate) { '' } else { ([string]$signature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant() } if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $actual -cne $ExpectedThumbprint.ToUpperInvariant()) { Throw-UatError $Code } } function Find-SigningCertificate([string]$Thumbprint) { $normalized = ($Thumbprint -replace '\s+', '').ToUpperInvariant() foreach ($location in @('CurrentUser', 'LocalMachine')) { $path = "Cert:\$location\TrustedPeople\$normalized" if (Test-Path -LiteralPath $path) { $certificate = Get-Item -LiteralPath $path if (-not $certificate.HasPrivateKey -or (Get-Date) -lt $certificate.NotBefore -or (Get-Date) -gt $certificate.NotAfter) { Throw-UatError 'signing_certificate_invalid' } $rsa = $certificate.PrivateKey -as [Security.Cryptography.RSACryptoServiceProvider] if ($null -eq $rsa) { Throw-UatError 'signing_certificate_not_rsa_csp' } return [pscustomobject]@{ Certificate = $certificate; Rsa = $rsa } } } Throw-UatError 'signing_certificate_not_found' } function New-CaseToken { $bytes = New-Object byte[] 32 $rng = [Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($bytes) return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } finally { $rng.Dispose() [Array]::Clear($bytes, 0, $bytes.Length) } } function Write-NewUtf8File([string]$Path, [string]$Text) { $bytes = $utf8.GetBytes($Text) $stream = [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough) try { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } $script:published.Add($Path) } function Write-RestrictedVault([string]$Path, [string]$Text) { $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User $systemSid = [Security.Principal.SecurityIdentifier]::new( [Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $security = New-Object Security.AccessControl.FileSecurity $security.SetOwner($currentSid) $security.SetAccessRuleProtection($true, $false) foreach ($sid in @($currentSid, $systemSid)) { $rule = [Security.AccessControl.FileSystemAccessRule]::new( $sid, [Security.AccessControl.FileSystemRights]::FullControl, [Security.AccessControl.AccessControlType]::Allow) $security.AddAccessRule($rule) } $bytes = $utf8.GetBytes($Text) $stream = [IO.FileStream]::new( $Path, [IO.FileMode]::CreateNew, [Security.AccessControl.FileSystemRights]::ReadData -bor [Security.AccessControl.FileSystemRights]::WriteData -bor [Security.AccessControl.FileSystemRights]::ReadAttributes -bor [Security.AccessControl.FileSystemRights]::WriteAttributes -bor [Security.AccessControl.FileSystemRights]::ReadPermissions, [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough, $security) try { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } $script:published.Add($Path) & "$env:SystemRoot\System32\icacls.exe" $Path '/setintegritylevel' 'H' | Out-Null if ($LASTEXITCODE -ne 0) { Throw-UatError 'token_vault_integrity_label_failed' } $sections = [Security.AccessControl.AccessControlSections]::All $acl = [IO.File]::GetAccessControl($Path, $sections) $ownerSid = $acl.GetOwner( [Security.Principal.SecurityIdentifier]).Value $rules = @($acl.GetAccessRules( $true, $true, [Security.Principal.SecurityIdentifier])) $seen = @{} foreach ($rule in $rules) { $sidValue = [string]$rule.IdentityReference.Value if ($rule.IsInherited -or $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or ($sidValue -cne $currentSid.Value -and $sidValue -cne $systemSid.Value) -or (($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl) -or $seen.ContainsKey($sidValue)) { Throw-UatError 'token_vault_acl_invalid' } $seen[$sidValue] = $true } $sddl = $acl.GetSecurityDescriptorSddlForm($sections) if (-not $acl.AreAccessRulesProtected -or $ownerSid -cne $currentSid.Value -or $rules.Count -ne 2 -or -not $seen.ContainsKey($currentSid.Value) -or -not $seen.ContainsKey($systemSid.Value) -or $sddl -cnotmatch 'S:.*\(ML;;NW;;;HI\)') { Throw-UatError 'token_vault_acl_invalid' } } 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-PackagedRuntimeCliContract([string]$PackagePath) { try { Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [IO.Compression.ZipFile]::OpenRead($PackagePath) try { $manifestEntries = @($archive.Entries | Where-Object { [string]$_.FullName -cmatch '(?:^|/)SHA256SUMS\.json$' }) $runtimeEntries = @($archive.Entries | Where-Object { [string]$_.FullName -cmatch '(?:^|/)Host/lserp-agent-cli\.exe$' }) if ($manifestEntries.Count -ne 1 -or $runtimeEntries.Count -ne 1 -or $manifestEntries[0].Length -le 0 -or $manifestEntries[0].Length -gt 4MB -or $runtimeEntries[0].Length -le 0 -or $runtimeEntries[0].Length -gt 128MB) { Throw-UatError 'package_runtime_cli_missing' } $manifestStream = $manifestEntries[0].Open() $manifestReader = $null try { $manifestReader = [IO.StreamReader]::new( $manifestStream, $utf8, $false, 4096, $false) $manifestText = $manifestReader.ReadToEnd() } finally { if ($null -ne $manifestReader) { $manifestReader.Dispose() } else { $manifestStream.Dispose() } } try { $manifest = $manifestText | ConvertFrom-Json } catch { Throw-UatError 'package_manifest_invalid' } if (-not (Test-ExactProperties $manifest @( 'schemaVersion', 'packageVersion', 'generatedAtUtc', 'files')) -or [string]$manifest.schemaVersion -cne '1.0' -or [string]$manifest.packageVersion -cne $ExpectedRuntimeCliVersion -or $manifest.files -isnot [array]) { Throw-UatError 'package_manifest_invalid' } $manifestRuntime = @($manifest.files | Where-Object { [string]$_.path -ceq 'Host/lserp-agent-cli.exe' }) if ($manifestRuntime.Count -ne 1 -or -not (Test-ExactProperties $manifestRuntime[0] @( 'path', 'sizeBytes', 'sha256')) -or [long]$manifestRuntime[0].sizeBytes -ne [long]$runtimeEntries[0].Length -or [string]$manifestRuntime[0].sha256 -cnotmatch '^[a-f0-9]{64}$') { Throw-UatError 'package_runtime_cli_manifest_invalid' } $runtimeStream = $runtimeEntries[0].Open() $sha = [Security.Cryptography.SHA256]::Create() try { $archiveRuntimeHash = ([BitConverter]::ToString( $sha.ComputeHash($runtimeStream))).Replace( '-', '').ToLowerInvariant() } finally { $sha.Dispose() $runtimeStream.Dispose() } if ($archiveRuntimeHash -cne [string]$manifestRuntime[0].sha256) { Throw-UatError 'package_runtime_cli_hash_mismatch' } return [pscustomobject]@{ Version = [string]$manifest.packageVersion SizeBytes = [long]$runtimeEntries[0].Length Sha256 = $archiveRuntimeHash } } finally { $archive.Dispose() } } catch { if ($_.Exception.Message.StartsWith( 'workflow_uat_authorization_failed:')) { throw } Throw-UatError 'package_runtime_cli_invalid' } } $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' ) function Get-ExpectedCommand([string]$CaseCode) { if ($CaseCode.StartsWith('purchase_', [StringComparison]::Ordinal)) { return 'purchase.invoice.create' } if ($CaseCode -in @( 'leave_natural_language_resolution', 'leave_multi_day_calendar_resolution', 'leave_ambiguous_type_blocked', 'leave_ambiguous_flow_type_blocked', 'leave_time_segment_required_blocked', 'leave_other_employee_denied')) { return 'hr.leave.resolve' } if ($CaseCode -eq 'leave_submit_separate_confirmation') { return 'hr.leave.submit' } return 'hr.leave.create' } function Get-AllowedCommands([string]$ExpectedCommand) { if ($ExpectedCommand -eq 'purchase.invoice.create') { return @('purchase.invoice.resolve', 'purchase.invoice.create') } if ($ExpectedCommand -eq 'hr.leave.create') { return @('hr.leave.resolve', 'hr.leave.create') } return @($ExpectedCommand) } try { if (-not $DatabaseBackupVerified.IsPresent -or -not $RestoreProcedureVerified.IsPresent -or -not $NonProductionEnvironmentVerified.IsPresent -or -not $NativeConfirmationVerified.IsPresent -or -not $TransactionAuditVerified.IsPresent) { Throw-UatError 'explicit_safety_attestation_required' } if (($Workflow -in @('purchase', 'both')) -and ([string]::IsNullOrWhiteSpace($PurchaseModuleCode) -or [string]::IsNullOrWhiteSpace($PurchaseAdapterId) -or [string]::IsNullOrWhiteSpace($PurchaseAdapterVersion))) { Throw-UatError 'purchase_contract_required' } if (($Workflow -in @('leave', 'both')) -and ([string]::IsNullOrWhiteSpace($LeaveModuleCode) -or [string]::IsNullOrWhiteSpace($LeaveAdapterId) -or [string]::IsNullOrWhiteSpace($LeaveAdapterVersion))) { Throw-UatError 'leave_contract_required' } if ($AccountBook -cne $AccountBook.Trim() -or $ErpUserName -cne $ErpUserName.Trim() -or $ApprovedBy -cne $ApprovedBy.Trim() -or $ErpUserName -match '[\x00-\x1F\x7F]') { Throw-UatError 'scope_text_invalid' } $runtime = Open-LockedInput $RuntimeConfigurationFile 1MB '' 'runtime_configuration_invalid' $profile = Open-LockedInput $CustomerProfileFile 4MB '' 'customer_profile_invalid' $rollout = Open-LockedInput $RolloutPolicyFile 256KB '' 'rollout_policy_invalid' $package = Open-LockedInput $CommercialPackageFile 4GB '' 'commercial_package_invalid' $erp = Open-LockedInput $ErpExecutablePath 256MB 'Ls_ERP.exe' 'erp_executable_invalid' $runtimeCli = Open-LockedInput ` $RuntimeCliPath 128MB 'lserp-agent-cli.exe' 'runtime_cli_invalid' $cli = Open-LockedInput $VerifierCliPath 128MB 'lserp-cli.exe' 'verifier_cli_invalid' $output = Resolve-NewPath $OutputPath '.json' 'authorization_output_invalid' $vaultOutput = Resolve-NewPath $TokenVaultPath '.json' 'token_vault_output_invalid' if ($output -ieq $vaultOutput) { Throw-UatError 'output_path_conflict' } try { $rolloutDocument = (Get-LockedUtf8Text ` $rollout.Stream 'rollout_policy_invalid') | ConvertFrom-Json } catch { if ($_.Exception.Message.StartsWith('workflow_uat_authorization_failed:')) { throw } Throw-UatError 'rollout_policy_invalid' } if (-not (Test-ExactProperties $rolloutDocument @( 'schemaVersion', 'customerId', 'databaseScopeFingerprint', 'defaultAction', 'rules')) -or [string]$rolloutDocument.schemaVersion -cne '1.1' -or [string]$rolloutDocument.customerId -cne $CustomerId -or [string]$rolloutDocument.databaseScopeFingerprint -cne $DatabaseScopeFingerprint.ToLowerInvariant() -or [string]$rolloutDocument.defaultAction -cne 'deny' -or $null -eq $rolloutDocument.rules) { Throw-UatError 'rollout_policy_scope_mismatch' } $packageHash = Get-LockedSha256 $package.Stream if ($packageHash -cne $ExpectedPackageSha256.ToLowerInvariant()) { Throw-UatError 'commercial_package_hash_mismatch' } $packageRuntimeCli = Get-PackagedRuntimeCliContract $package.Path $runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream if ($packageRuntimeCli.Version -cne $ExpectedRuntimeCliVersion -or $packageRuntimeCli.SizeBytes -ne $runtimeCli.Stream.Length -or $packageRuntimeCli.Sha256 -cne $runtimeCliHash) { Throw-UatError 'runtime_cli_package_binding_mismatch' } Assert-Authenticode $erp.Path $ExpectedErpSignerThumbprint 'erp_authenticode_invalid' Assert-Authenticode $runtimeCli.Path ` $ExpectedRuntimeCliSignerThumbprint 'runtime_cli_authenticode_invalid' Assert-Authenticode $cli.Path $ExpectedCliSignerThumbprint 'cli_authenticode_invalid' $runtimeIdentityCorrelation = 'uat-runtime-version-' + [Guid]::NewGuid().ToString('N') $runtimeIdentityOutput = @(& $runtimeCli.Path version ` --correlation-id $runtimeIdentityCorrelation 2>&1) $runtimeIdentityExit = $LASTEXITCODE $runtimeIdentityText = (($runtimeIdentityOutput | ForEach-Object { [string]$_ }) -join [Environment]::NewLine) try { $runtimeIdentityEnvelope = $runtimeIdentityText | ConvertFrom-Json } catch { Throw-UatError 'runtime_cli_identity_invalid' } $runtimeIdentity = $runtimeIdentityEnvelope.data if ($runtimeIdentityExit -ne 0 -or -not (Test-ExactProperties $runtimeIdentityEnvelope @( 'ok', 'correlationId', 'data')) -or $runtimeIdentityEnvelope.ok -ne $true -or [string]$runtimeIdentityEnvelope.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 -ne $true -or $runtimeIdentity.databaseDirectAccess -ne $false -or [string]$runtimeIdentity.sessionSource -cne 'current_logged_in_erp_process' -or (Get-LockedSha256 $runtimeCli.Stream) -cne $runtimeCliHash) { Throw-UatError 'runtime_cli_identity_invalid' } $issuedAt = [DateTime]::UtcNow $expiresAt = $issuedAt.AddHours($ValidHours) $entropy = $utf8.GetBytes($AuthorizationId) $vaultEntries = New-Object System.Collections.Generic.List[object] $workflowObjects = New-Object System.Collections.Generic.List[object] $workflowNames = if ($Workflow -eq 'both') { @('purchase', 'leave') } else { @($Workflow) } foreach ($workflowName in $workflowNames) { $caseCodes = if ($workflowName -eq 'purchase') { $purchaseCases } else { $leaveCases } $caseObjects = New-Object System.Collections.Generic.List[object] foreach ($caseCode in $caseCodes) { $token = New-CaseToken $expectedCommand = Get-ExpectedCommand $caseCode $plainTokenBytes = $utf8.GetBytes($token) try { $tokenHash = Get-Sha256Hex $plainTokenBytes $protected = [Security.Cryptography.ProtectedData]::Protect( $plainTokenBytes, $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser) } finally { [Array]::Clear( $plainTokenBytes, 0, $plainTokenBytes.Length) } $caseObjects.Add([pscustomobject][ordered]@{ caseCode = $caseCode expectedCommandName = $expectedCommand allowedCommands = @(Get-AllowedCommands $expectedCommand) tokenSha256 = $tokenHash }) $vaultEntries.Add([pscustomobject][ordered]@{ workflow = $workflowName caseCode = $caseCode protectedTokenBase64 = [Convert]::ToBase64String($protected) }) $token = $null $tokenHash = $null $plainTokenBytes = $null } $workflowObjects.Add([pscustomobject][ordered]@{ workflow = $workflowName moduleCode = if ($workflowName -eq 'purchase') { $PurchaseModuleCode } else { $LeaveModuleCode } adapterId = if ($workflowName -eq 'purchase') { $PurchaseAdapterId } else { $LeaveAdapterId } adapterVersion = if ($workflowName -eq 'purchase') { $PurchaseAdapterVersion } else { $LeaveAdapterVersion } cases = @($caseObjects) }) } $content = [pscustomobject][ordered]@{ packageType = 'workflow_write_uat_authorization' authorizationId = $AuthorizationId customerId = $CustomerId environmentId = $EnvironmentId environmentClass = 'recoverable_uat' erpScope = [pscustomobject][ordered]@{ accountBook = $AccountBook subSystemId = $SubSystemId userId = $ErpUserId userName = $ErpUserName databaseScopeFingerprint = $DatabaseScopeFingerprint.ToLowerInvariant() } runtimeConfigurationSha256 = Get-LockedSha256 $runtime.Stream customerProfileSha256 = Get-LockedSha256 $profile.Stream rolloutPolicySha256 = Get-LockedSha256 $rollout.Stream sourceCommit = $SourceCommit.ToLowerInvariant() packageSha256 = $packageHash erpExecutable = [pscustomobject][ordered]@{ fileName = 'Ls_ERP.exe' sha256 = Get-LockedSha256 $erp.Stream signerThumbprint = $ExpectedErpSignerThumbprint.ToUpperInvariant() requiresElevation = $false } runtimeCli = [pscustomobject][ordered]@{ fileName = 'lserp-agent-cli.exe' version = $ExpectedRuntimeCliVersion sha256 = $runtimeCliHash signerThumbprint = $ExpectedRuntimeCliSignerThumbprint.ToUpperInvariant() requiresElevation = $false bridgeOnly = $true databaseDirectAccess = $false sessionSource = 'current_logged_in_erp_process' } verifierCli = [pscustomobject][ordered]@{ fileName = 'lserp-cli.exe' sha256 = Get-LockedSha256 $cli.Stream signerThumbprint = $ExpectedCliSignerThumbprint.ToUpperInvariant() requiresElevation = $true } safety = [pscustomobject][ordered]@{ databaseBackupVerified = $true restoreProcedureVerified = $true nonProductionEnvironmentVerified = $true productionUseProhibited = $true nativeConfirmationRequired = $true transactionAndAuditRequired = $true maximumPlanAttemptsPerCase = 6 maximumExecuteAttemptsPerCase = 3 } workflows = @($workflowObjects) issuedAtUtc = $issuedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') expiresAtUtc = $expiresAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') approvedBy = $ApprovedBy note = '仅授权在已验证备份和恢复流程的客户 UAT 库收集固定写集成用例;严禁生产使用。' } $contentJson = $content | ConvertTo-Json -Depth 20 -Compress $contentBytes = $utf8.GetBytes($contentJson) $contentHash = Get-Sha256Hex $contentBytes $signer = Find-SigningCertificate $CertificateThumbprint $sha = [Security.Cryptography.SHA256]::Create() try { $digest = $sha.ComputeHash($contentBytes) } finally { $sha.Dispose() } $signatureBytes = $signer.Rsa.SignHash( $digest, [Security.Cryptography.CryptoConfig]::MapNameToOID('SHA256')) $root = [pscustomobject][ordered]@{ schemaVersion = '1.2' contentSha256 = $contentHash signatureAlgorithm = 'rsa-sha256' certificateThumbprint = ($CertificateThumbprint -replace '\s+', '').ToUpperInvariant() signatureBase64 = [Convert]::ToBase64String($signatureBytes) content = $content } $vault = [pscustomobject][ordered]@{ schemaVersion = '1.0' authorizationId = $AuthorizationId protectedForUserSid = $identity.User.Value protectionScope = 'dpapi_current_user_high_integrity' createdAtUtc = $issuedAt.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') entries = @($vaultEntries) } Write-RestrictedVault $vaultOutput ($vault | ConvertTo-Json -Depth 8 -Compress) Write-NewUtf8File $output ($root | ConvertTo-Json -Depth 24 -Compress) foreach ($input in @( $runtime, $profile, $rollout, $package, $erp, $runtimeCli, $cli)) { if ((Get-LockedSha256 $input.Stream) -cne $(if ($input -eq $runtime) { $content.runtimeConfigurationSha256 } elseif ($input -eq $profile) { $content.customerProfileSha256 } elseif ($input -eq $rollout) { $content.rolloutPolicySha256 } elseif ($input -eq $package) { $content.packageSha256 } elseif ($input -eq $erp) { $content.erpExecutable.sha256 } elseif ($input -eq $runtimeCli) { $content.runtimeCli.sha256 } else { $content.verifierCli.sha256 })) { Throw-UatError 'locked_input_changed' } } $verifyOutput = @(& $cli.Path 'acceptance' 'verify-uat-authorization' ` '--input' $output '--json' 2>&1) $verifyExit = $LASTEXITCODE if ($verifyExit -ne 0) { Throw-UatError 'self_verification_failed' } try { $verified = (($verifyOutput | ForEach-Object { [string]$_ }) -join "`n") | ConvertFrom-Json } catch { Throw-UatError 'self_verification_invalid_json' } if (-not (Test-ExactProperties $verified @('ok', 'correlationId', 'data')) -or $verified.ok -ne $true -or $verified.data.packageType -cne 'workflow_write_uat_authorization' -or $verified.data.schemaVersion -cne '1.2' -or $verified.data.authorizationId -cne $AuthorizationId -or $verified.data.sourceSha256 -cne (Get-FileHash -LiteralPath $output -Algorithm SHA256).Hash.ToLowerInvariant() -or $verified.data.runtimeConfigurationSha256 -cne $content.runtimeConfigurationSha256 -or $verified.data.customerProfileSha256 -cne $content.customerProfileSha256 -or $verified.data.rolloutPolicySha256 -cne $content.rolloutPolicySha256 -or $verified.data.packageSha256 -cne $packageHash -or [string]$verified.data.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or [string]$verified.data.runtimeCli.version -cne $ExpectedRuntimeCliVersion -or [string]$verified.data.runtimeCli.sha256 -cne $runtimeCliHash -or [string]$verified.data.runtimeCli.signerThumbprint -cne $ExpectedRuntimeCliSignerThumbprint.ToUpperInvariant() -or $verified.data.runtimeCli.requiresElevation -ne $false -or $verified.data.runtimeCli.bridgeOnly -ne $true -or $verified.data.runtimeCli.databaseDirectAccess -ne $false -or [string]$verified.data.runtimeCli.sessionSource -cne 'current_logged_in_erp_process' -or $verified.data.erpScope.userIdSha256 -cne (Get-Sha256Hex ($utf8.GetBytes($ErpUserId))) -or $verified.data.erpScope.userNameSha256 -cne (Get-Sha256Hex ($utf8.GetBytes($ErpUserName))) -or $verified.data.erpScope.databaseScopeFingerprint -cne $DatabaseScopeFingerprint.ToLowerInvariant() -or $verified.data.signatureVerified -ne $true -or $verified.data.uatAuthorized -ne $true -or $verified.data.productionReady -ne $false) { Throw-UatError 'self_verification_contract_mismatch' } [pscustomobject]@{ authorizationPath = $output authorizationSourceSha256 = [string]$verified.data.sourceSha256 authorizationContentSha256 = $contentHash authorizationId = $AuthorizationId tokenVaultPath = $vaultOutput tokenVaultContainsPlaintext = $false expiresAtUtc = $content.expiresAtUtc workflowCount = @($workflowObjects).Count caseCount = @($vaultEntries).Count productionReady = $false } } catch { foreach ($path in @($published)) { if ([IO.File]::Exists($path)) { try { [IO.File]::Delete($path) } catch { } } } throw } finally { $token = $null $tokenHash = $null if ($null -ne $plainTokenBytes) { [Array]::Clear($plainTokenBytes, 0, $plainTokenBytes.Length) $plainTokenBytes = $null } foreach ($stream in @($locks)) { if ($null -ne $stream) { $stream.Dispose() } } }