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

842 lines
35 KiB
PowerShell

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