842 lines
35 KiB
PowerShell
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() }
|
|
}
|
|
}
|