feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
[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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user