932 lines
43 KiB
PowerShell
932 lines
43 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]{7,63}$')]
|
|
[string]$CampaignId,
|
|
|
|
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
|
|
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
|
|
[Parameter(Mandatory = $true)][string]$RuntimeCliPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedUatAuthorizationSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedVerifierCliSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
|
|
[string]$ExpectedRuntimeCliVersion,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
|
[string]$ExpectedRuntimeCliSha256,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
|
[string]$ExpectedVerifierSignerThumbprint,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
|
[string]$ExpectedRuntimeSignerThumbprint,
|
|
[string]$CaseCatalogFile = (Join-Path $PSScriptRoot `
|
|
'workflow-write-uat-case-catalog.v1.json'),
|
|
[Parameter(Mandatory = $true)][string]$OutputRoot,
|
|
[ValidateRange(1000, 60000)][int]$CliTimeoutMilliseconds = 30000
|
|
)
|
|
|
|
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_campaign_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_campaign_failed:elevated_operator_required'
|
|
}
|
|
|
|
$utf8 = [Text.UTF8Encoding]::new($false, $true)
|
|
$maximumResponseCharacters = 4 * 1024 * 1024
|
|
$locks = New-Object System.Collections.Generic.List[IO.FileStream]
|
|
$campaignDirectory = $null
|
|
$expectedCaseCatalogSha256 = `
|
|
'23eb6c4f308d4904bf3920ed37499f05521beebde9422026f9732983c16002d5'
|
|
|
|
function Throw-CampaignError([string]$Code) {
|
|
throw ('workflow_uat_campaign_failed:' + $Code)
|
|
}
|
|
|
|
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 Test-ExactStringArray([object[]]$Actual, [string[]]$Expected) {
|
|
$values = @($Actual)
|
|
if ($values.Count -ne $Expected.Count) { return $false }
|
|
for ($index = 0; $index -lt $Expected.Count; $index++) {
|
|
if ([string]$values[$index] -cne $Expected[$index]) { return $false }
|
|
}
|
|
return $true
|
|
}
|
|
|
|
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-CampaignError $Code
|
|
}
|
|
$current = $current.Parent
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
|
Throw-CampaignError $Code
|
|
}
|
|
}
|
|
|
|
function Open-LockedRegularFile(
|
|
[string]$Path,
|
|
[long]$MaximumBytes,
|
|
[string]$ExpectedFileName,
|
|
[string]$Code
|
|
) {
|
|
try {
|
|
$full = [IO.Path]::GetFullPath($Path)
|
|
if (-not [IO.File]::Exists($full)) { Throw-CampaignError $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-CampaignError $Code
|
|
}
|
|
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
|
|
$stream = [IO.File]::Open(
|
|
$full, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
|
$script:locks.Add($stream)
|
|
return [pscustomobject]@{ Path = $full; Stream = $stream }
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
|
Throw-CampaignError $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
|
|
$value = ([BitConverter]::ToString($sha.ComputeHash($Stream))).Replace('-', '').ToLowerInvariant()
|
|
$Stream.Position = 0
|
|
return $value
|
|
}
|
|
finally { $sha.Dispose() }
|
|
}
|
|
|
|
function Read-LockedJson([object]$LockedFile, [string]$Code) {
|
|
try {
|
|
$LockedFile.Stream.Position = 0
|
|
$reader = New-Object IO.StreamReader(
|
|
$LockedFile.Stream, $utf8, $true, 4096, $true)
|
|
try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
|
$LockedFile.Stream.Position = 0
|
|
return $text | ConvertFrom-Json
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
|
Throw-CampaignError $Code
|
|
}
|
|
}
|
|
|
|
function ConvertTo-UnixSeconds([object]$Value) {
|
|
$timestamp = ([DateTimeOffset]$Value).ToUniversalTime()
|
|
$epoch = [DateTimeOffset]::new(
|
|
1970, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
|
return [int64][Math]::Floor(($timestamp - $epoch).TotalSeconds)
|
|
}
|
|
|
|
function ConvertTo-WindowsProcessArgument([string]$Value) {
|
|
if ($null -eq $Value -or $Value.Length -eq 0) { return '""' }
|
|
if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) { return $Value }
|
|
$builder = New-Object Text.StringBuilder
|
|
[void]$builder.Append([char]34)
|
|
$slashes = 0
|
|
foreach ($character in $Value.ToCharArray()) {
|
|
if ([int]$character -eq 92) { $slashes++; continue }
|
|
if ([int]$character -eq 34) {
|
|
for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append([char]34)
|
|
}
|
|
else {
|
|
for ($index = 0; $index -lt $slashes; $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append($character)
|
|
}
|
|
$slashes = 0
|
|
}
|
|
for ($index = 0; $index -lt ($slashes * 2); $index++) {
|
|
[void]$builder.Append([char]92)
|
|
}
|
|
[void]$builder.Append([char]34)
|
|
return $builder.ToString()
|
|
}
|
|
|
|
function Invoke-TrustedCli([string]$CliPath, [string[]]$Arguments) {
|
|
$process = New-Object Diagnostics.Process
|
|
try {
|
|
$start = New-Object Diagnostics.ProcessStartInfo
|
|
$start.FileName = $CliPath
|
|
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($CliPath)
|
|
$start.UseShellExecute = $false
|
|
$start.CreateNoWindow = $true
|
|
$start.RedirectStandardOutput = $true
|
|
$start.RedirectStandardError = $true
|
|
$start.RedirectStandardInput = $true
|
|
$start.StandardOutputEncoding = $utf8
|
|
$start.StandardErrorEncoding = $utf8
|
|
$start.Arguments = (($Arguments | ForEach-Object {
|
|
ConvertTo-WindowsProcessArgument ([string]$_)
|
|
}) -join ' ')
|
|
$process.StartInfo = $start
|
|
if (-not $process.Start()) { Throw-CampaignError 'cli_process_start_failed' }
|
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
|
$process.StandardInput.Close()
|
|
if (-not $process.WaitForExit($CliTimeoutMilliseconds)) {
|
|
try { $process.Kill() } catch { }
|
|
Throw-CampaignError 'cli_timeout'
|
|
}
|
|
$process.WaitForExit()
|
|
$stdout = $stdoutTask.Result
|
|
$stderr = $stderrTask.Result
|
|
if ($process.ExitCode -ne 0 -or
|
|
[string]::IsNullOrWhiteSpace($stdout) -or
|
|
-not [string]::IsNullOrWhiteSpace($stderr) -or
|
|
$stdout.Length -gt $maximumResponseCharacters) {
|
|
Throw-CampaignError 'uat_authorization_verification_failed'
|
|
}
|
|
try { $envelope = $stdout | ConvertFrom-Json }
|
|
catch { Throw-CampaignError 'cli_response_invalid' }
|
|
if (-not (Test-ExactProperties $envelope @('ok', 'correlationId', 'data')) -or
|
|
$envelope.ok -ne $true -or
|
|
([string]$envelope.correlationId) -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') {
|
|
Throw-CampaignError 'cli_response_invalid'
|
|
}
|
|
return $envelope.data
|
|
}
|
|
finally { $process.Dispose() }
|
|
}
|
|
|
|
function Assert-RestrictedDirectoryAcl([string]$Path) {
|
|
try {
|
|
$sections = [Security.AccessControl.AccessControlSections]::All
|
|
$acl = [IO.Directory]::GetAccessControl($Path, $sections)
|
|
$owner = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
$currentSid = $identity.User.Value
|
|
$systemSid = [Security.Principal.SecurityIdentifier]::new(
|
|
[Security.Principal.WellKnownSidType]::LocalSystemSid, $null).Value
|
|
$rules = @($acl.GetAccessRules(
|
|
$true, $false, [Security.Principal.SecurityIdentifier]))
|
|
$seen = @{}
|
|
foreach ($rule in $rules) {
|
|
$sid = $rule.IdentityReference.Value
|
|
if ($rule.AccessControlType -ne
|
|
[Security.AccessControl.AccessControlType]::Allow -or
|
|
$sid -cnotin @($currentSid, $systemSid) -or
|
|
(($rule.FileSystemRights -band
|
|
[Security.AccessControl.FileSystemRights]::FullControl) -ne
|
|
[Security.AccessControl.FileSystemRights]::FullControl) -or
|
|
$seen.ContainsKey($sid)) {
|
|
Throw-CampaignError 'campaign_directory_acl_invalid'
|
|
}
|
|
$seen[$sid] = $true
|
|
}
|
|
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
|
|
if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid -or
|
|
$rules.Count -ne 2 -or -not $seen.ContainsKey($currentSid) -or
|
|
-not $seen.ContainsKey($systemSid) -or
|
|
$sddl -cnotmatch
|
|
'S:.*\(ML;(?=[A-Z]*OI)(?=[A-Z]*CI)[A-Z]*;NW;;;HI\)') {
|
|
Throw-CampaignError 'campaign_directory_acl_invalid'
|
|
}
|
|
}
|
|
catch {
|
|
if ($_.Exception.Message.StartsWith('workflow_uat_campaign_failed:')) { throw }
|
|
Throw-CampaignError 'campaign_directory_acl_invalid'
|
|
}
|
|
}
|
|
|
|
function New-RestrictedCampaignDirectory([string]$Root, [string]$Name) {
|
|
$fullRoot = [IO.Path]::GetFullPath($Root)
|
|
if (-not [IO.Directory]::Exists($fullRoot)) {
|
|
Throw-CampaignError 'output_root_invalid'
|
|
}
|
|
Assert-NoReparseDirectoryChain $fullRoot 'output_root_invalid'
|
|
$target = Join-Path $fullRoot $Name
|
|
if ([IO.File]::Exists($target) -or [IO.Directory]::Exists($target)) {
|
|
Throw-CampaignError 'campaign_directory_exists'
|
|
}
|
|
[IO.Directory]::CreateDirectory($target) | Out-Null
|
|
$currentUser = $identity.User
|
|
$localSystem = [Security.Principal.SecurityIdentifier]::new(
|
|
[Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
|
|
$security = New-Object Security.AccessControl.DirectorySecurity
|
|
$security.SetOwner($currentUser)
|
|
$security.SetAccessRuleProtection($true, $false)
|
|
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor
|
|
[Security.AccessControl.InheritanceFlags]::ObjectInherit
|
|
foreach ($principalSid in @($currentUser, $localSystem)) {
|
|
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
|
|
$principalSid,
|
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
|
$inheritance,
|
|
[Security.AccessControl.PropagationFlags]::None,
|
|
[Security.AccessControl.AccessControlType]::Allow)
|
|
$security.AddAccessRule($rule)
|
|
}
|
|
[IO.Directory]::SetAccessControl($target, $security)
|
|
& "$env:SystemRoot\System32\icacls.exe" `
|
|
$target '/setintegritylevel' '(OI)(CI)H' | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { Throw-CampaignError 'campaign_directory_acl_invalid' }
|
|
Assert-RestrictedDirectoryAcl $target
|
|
return $target
|
|
}
|
|
|
|
function Write-NewJson([string]$Path, [object]$Value, [int]$Depth) {
|
|
$bytes = $utf8.GetBytes(($Value | ConvertTo-Json -Depth $Depth) + [Environment]::NewLine)
|
|
$stream = [IO.File]::Open(
|
|
$Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
|
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush() }
|
|
finally { $stream.Dispose() }
|
|
}
|
|
|
|
$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'
|
|
)
|
|
$executeCases = @(
|
|
'purchase_unique_match_commit',
|
|
'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',
|
|
'leave_database_permission_recheck_denied', 'leave_create_draft_commit',
|
|
'leave_submit_separate_confirmation', 'leave_stale_flow_type_blocked',
|
|
'leave_runtime_recheck_blocked', 'leave_transaction_rollback',
|
|
'leave_idempotency_replay', 'leave_idempotency_conflict'
|
|
)
|
|
$derivedCases = @('purchase_audit_correlated', 'leave_audit_correlated')
|
|
$postPlanStagingCases = @(
|
|
'purchase_runtime_recheck_blocked',
|
|
'leave_stale_flow_type_blocked',
|
|
'leave_runtime_recheck_blocked'
|
|
)
|
|
|
|
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]$CommandName) {
|
|
if ($CommandName -eq 'purchase.invoice.create') {
|
|
return @('purchase.invoice.resolve', 'purchase.invoice.create')
|
|
}
|
|
if ($CommandName -eq 'hr.leave.create') {
|
|
return @('hr.leave.resolve', 'hr.leave.create')
|
|
}
|
|
return @($CommandName)
|
|
}
|
|
|
|
function Get-Dependency([string]$CaseCode) {
|
|
if ($CaseCode -in @(
|
|
'purchase_idempotency_replay', 'purchase_idempotency_conflict',
|
|
'purchase_audit_correlated')) {
|
|
return 'purchase_unique_match_commit'
|
|
}
|
|
if ($CaseCode -in @(
|
|
'leave_submit_separate_confirmation', 'leave_idempotency_replay',
|
|
'leave_idempotency_conflict', 'leave_audit_correlated')) {
|
|
return 'leave_create_draft_commit'
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Get-IdempotencyPolicy([string]$CaseCode, [string]$CaptureMode) {
|
|
if ($CaptureMode -eq 'plan_only') { return 'not_applicable' }
|
|
if ($CaptureMode -eq 'derived_audit') { return 'derived_no_execute' }
|
|
if ($CaseCode.EndsWith('_idempotency_replay', [StringComparison]::Ordinal)) {
|
|
return 'reuse_dependency_key_and_input'
|
|
}
|
|
if ($CaseCode.EndsWith('_idempotency_conflict', [StringComparison]::Ordinal)) {
|
|
return 'reuse_dependency_key_with_different_input'
|
|
}
|
|
return 'new_unique_key'
|
|
}
|
|
|
|
function Test-SafeCatalogText([object]$Value, [int]$MaximumLength) {
|
|
if ($null -eq $Value) { return $false }
|
|
$text = [string]$Value
|
|
if ([string]::IsNullOrWhiteSpace($text) -or
|
|
$text.Length -gt $MaximumLength -or
|
|
$text -cne $text.Trim() -or
|
|
[Text.RegularExpressions.Regex]::IsMatch($text, '[\x00-\x1f\x7f]') -or
|
|
[Text.RegularExpressions.Regex]::IsMatch(
|
|
$text,
|
|
'(?i)(?:https?|jdbc|file)://|\b(?:password|passwd|secret|api[_ -]?key|token)\b|\b(?:\d{1,3}\.){3}\d{1,3}\b|sk-[A-Za-z0-9_-]{8,}|\b(?:insert\s+into|update\s+\S+\s+set|delete\s+from|drop\s+table|truncate\s+table|alter\s+table)\b')) {
|
|
return $false
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function Test-SafeCatalogTextArray(
|
|
[object]$Value,
|
|
[int]$MinimumCount,
|
|
[int]$MaximumCount
|
|
) {
|
|
if ($null -eq $Value -or -not ($Value -is [Array])) { return $false }
|
|
$items = @($Value)
|
|
if ($items.Count -lt $MinimumCount -or $items.Count -gt $MaximumCount) {
|
|
return $false
|
|
}
|
|
foreach ($item in $items) {
|
|
if (-not (Test-SafeCatalogText $item 500)) { return $false }
|
|
}
|
|
return $true
|
|
}
|
|
|
|
function Assert-CaseCatalog([object]$Catalog) {
|
|
if (-not (Test-ExactProperties $Catalog @(
|
|
'schemaVersion', 'packageType', 'safety', 'workflows')) -or
|
|
[string]$Catalog.schemaVersion -cne '1.0' -or
|
|
[string]$Catalog.packageType -cne 'workflow_write_uat_case_catalog' -or
|
|
-not (Test-ExactProperties $Catalog.safety @(
|
|
'productionUseProhibited',
|
|
'automaticDatabaseOrConfigurationChanges',
|
|
'approvedRestorePointRequired', 'containsCredentials',
|
|
'containsBusinessIdentifiers', 'executableInstructionsIncluded')) -or
|
|
$Catalog.safety.productionUseProhibited -ne $true -or
|
|
$Catalog.safety.automaticDatabaseOrConfigurationChanges -ne $false -or
|
|
$Catalog.safety.approvedRestorePointRequired -ne $true -or
|
|
$Catalog.safety.containsCredentials -ne $false -or
|
|
$Catalog.safety.containsBusinessIdentifiers -ne $false -or
|
|
$Catalog.safety.executableInstructionsIncluded -ne $false) {
|
|
Throw-CampaignError 'case_catalog_safety_invalid'
|
|
}
|
|
|
|
$workflows = @($Catalog.workflows)
|
|
if ($workflows.Count -ne 2) {
|
|
Throw-CampaignError 'case_catalog_workflow_coverage_invalid'
|
|
}
|
|
$lookup = @{}
|
|
for ($workflowIndex = 0; $workflowIndex -lt 2; $workflowIndex++) {
|
|
$workflowName = if ($workflowIndex -eq 0) { 'purchase' } else { 'leave' }
|
|
$expectedCases = if ($workflowName -eq 'purchase') {
|
|
$purchaseCases
|
|
} else { $leaveCases }
|
|
$workflow = $workflows[$workflowIndex]
|
|
if (-not (Test-ExactProperties $workflow @(
|
|
'workflow', 'caseCount', 'cases')) -or
|
|
[string]$workflow.workflow -cne $workflowName -or
|
|
[int]$workflow.caseCount -ne $expectedCases.Count) {
|
|
Throw-CampaignError 'case_catalog_workflow_contract_invalid'
|
|
}
|
|
$cases = @($workflow.cases)
|
|
if ($cases.Count -ne $expectedCases.Count) {
|
|
Throw-CampaignError 'case_catalog_case_coverage_invalid'
|
|
}
|
|
for ($index = 0; $index -lt $expectedCases.Count; $index++) {
|
|
$caseCode = $expectedCases[$index]
|
|
$case = $cases[$index]
|
|
$expectedCommand = Get-ExpectedCommand $caseCode
|
|
$expectedCaptureMode = if ($caseCode -in $derivedCases) {
|
|
'derived_audit'
|
|
} elseif ($caseCode -in $executeCases) {
|
|
'execute'
|
|
} else { 'plan_only' }
|
|
$expectedMutation = if ($caseCode -in @(
|
|
'purchase_unique_match_commit', 'leave_create_draft_commit',
|
|
'leave_submit_separate_confirmation')) {
|
|
'positive'
|
|
} else { 'zero' }
|
|
$expectedConfirmation = if ($expectedCaptureMode -eq 'derived_audit') {
|
|
'inherited_required'
|
|
} elseif ($expectedCaptureMode -eq 'execute') {
|
|
'required'
|
|
} else { 'prohibited' }
|
|
$expectedSourceProof = $caseCode -in @(
|
|
'purchase_unique_match_commit', 'purchase_idempotency_replay',
|
|
'purchase_audit_correlated')
|
|
if (-not (Test-ExactProperties $case @(
|
|
'sequence', 'caseCode', 'title', 'commandName',
|
|
'captureMode', 'expectedResultCode', 'expectedIssueCode',
|
|
'expectedMutationPolicy', 'nativeConfirmationPolicy',
|
|
'minimumAuditEventCount', 'sourceDocumentProofRequired',
|
|
'primaryRole', 'supportingRoles', 'fixtureCode',
|
|
'preconditions', 'operatorSteps', 'dbaReadOnlyChecks',
|
|
'cleanupSteps', 'retryPolicy')) -or
|
|
[int]$case.sequence -ne ($index + 1) -or
|
|
[string]$case.caseCode -cne $caseCode -or
|
|
[string]$case.commandName -cne $expectedCommand -or
|
|
[string]$case.captureMode -cne $expectedCaptureMode -or
|
|
([string]$case.expectedResultCode) -cnotmatch
|
|
'^[a-z][a-z0-9_]{2,95}$' -or
|
|
($null -ne $case.expectedIssueCode -and
|
|
([string]$case.expectedIssueCode) -cnotmatch
|
|
'^[a-z][a-z0-9_]{2,95}$') -or
|
|
[string]$case.expectedMutationPolicy -cne $expectedMutation -or
|
|
[string]$case.nativeConfirmationPolicy -cne $expectedConfirmation -or
|
|
[int]$case.minimumAuditEventCount -ne
|
|
$(if ($expectedCaptureMode -eq 'plan_only') { 1 } else { 2 }) -or
|
|
[bool]$case.sourceDocumentProofRequired -ne $expectedSourceProof -or
|
|
-not (Test-SafeCatalogText $case.title 120) -or
|
|
([string]$case.primaryRole) -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or
|
|
([string]$case.fixtureCode) -cnotmatch '^[a-z][a-z0-9_]{2,95}$' -or
|
|
([string]$case.retryPolicy) -cnotin @(
|
|
'single_success_then_relationship_cases_only',
|
|
'new_capture_allowed_while_authorization_active',
|
|
'new_plan_and_new_key_required',
|
|
'dependency_key_relationship_required',
|
|
'derived_with_dependency_only') -or
|
|
-not (Test-SafeCatalogTextArray $case.preconditions 1 12) -or
|
|
-not (Test-SafeCatalogTextArray $case.operatorSteps 1 12) -or
|
|
-not (Test-SafeCatalogTextArray $case.dbaReadOnlyChecks 1 12) -or
|
|
-not (Test-SafeCatalogTextArray $case.cleanupSteps 1 12) -or
|
|
$lookup.ContainsKey($caseCode)) {
|
|
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
|
}
|
|
if (-not ($case.supportingRoles -is [Array])) {
|
|
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
|
}
|
|
$roles = @($case.supportingRoles)
|
|
if ($roles.Count -lt 1 -or $roles.Count -gt 8 -or
|
|
@($roles | Select-Object -Unique).Count -ne $roles.Count) {
|
|
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
|
}
|
|
foreach ($role in $roles) {
|
|
if ([string]$role -cnotmatch '^[a-z][a-z0-9_]{2,63}$' -or
|
|
[string]$role -ceq [string]$case.primaryRole) {
|
|
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
|
}
|
|
}
|
|
$lookup[$caseCode] = $case
|
|
}
|
|
}
|
|
if ($lookup.Count -ne 32) {
|
|
Throw-CampaignError 'case_catalog_case_coverage_invalid'
|
|
}
|
|
return $lookup
|
|
}
|
|
|
|
try {
|
|
$caseCatalog = Open-LockedRegularFile `
|
|
$CaseCatalogFile (256KB) 'workflow-write-uat-case-catalog.v1.json' `
|
|
'case_catalog_file_invalid'
|
|
$authorization = Open-LockedRegularFile `
|
|
$UatAuthorizationFile (512KB) '' 'uat_authorization_file_invalid'
|
|
$verifierCli = Open-LockedRegularFile `
|
|
$VerifierCliPath (128MB) 'lserp-cli.exe' 'verifier_cli_invalid'
|
|
$runtimeCli = Open-LockedRegularFile `
|
|
$RuntimeCliPath (128MB) 'lserp-agent-cli.exe' 'runtime_cli_invalid'
|
|
if ($verifierCli.Path -ieq $runtimeCli.Path) {
|
|
Throw-CampaignError 'cli_role_path_conflict'
|
|
}
|
|
$caseCatalogHash = Get-LockedSha256 $caseCatalog.Stream
|
|
if ($caseCatalogHash -cne $expectedCaseCatalogSha256) {
|
|
Throw-CampaignError 'case_catalog_hash_mismatch'
|
|
}
|
|
$caseCatalogJson = Read-LockedJson $caseCatalog 'case_catalog_json_invalid'
|
|
$catalogCaseByCode = Assert-CaseCatalog $caseCatalogJson
|
|
$authorizationSourceHash = Get-LockedSha256 $authorization.Stream
|
|
if ($authorizationSourceHash -cne
|
|
$ExpectedUatAuthorizationSha256.ToLowerInvariant()) {
|
|
Throw-CampaignError 'uat_authorization_hash_mismatch'
|
|
}
|
|
$verifierCliHash = Get-LockedSha256 $verifierCli.Stream
|
|
$runtimeCliHash = Get-LockedSha256 $runtimeCli.Stream
|
|
if ($verifierCliHash -cne
|
|
$ExpectedVerifierCliSha256.ToLowerInvariant()) {
|
|
Throw-CampaignError 'verifier_cli_hash_mismatch'
|
|
}
|
|
if ($runtimeCliHash -cne $ExpectedRuntimeCliSha256.ToLowerInvariant()) {
|
|
Throw-CampaignError 'runtime_cli_hash_mismatch'
|
|
}
|
|
$verifierSignature = Get-AuthenticodeSignature -LiteralPath $verifierCli.Path
|
|
$verifierSigner = if ($null -eq $verifierSignature.SignerCertificate) { '' } else {
|
|
([string]$verifierSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant()
|
|
}
|
|
if ($verifierSignature.Status -ne
|
|
[Management.Automation.SignatureStatus]::Valid -or
|
|
$verifierSigner -cne
|
|
$ExpectedVerifierSignerThumbprint.ToUpperInvariant()) {
|
|
Throw-CampaignError 'verifier_cli_signature_invalid'
|
|
}
|
|
$runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCli.Path
|
|
$runtimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) { '' } else {
|
|
([string]$runtimeSignature.SignerCertificate.Thumbprint).Replace(' ', '').ToUpperInvariant()
|
|
}
|
|
if ($runtimeSignature.Status -ne
|
|
[Management.Automation.SignatureStatus]::Valid -or
|
|
$runtimeSigner -cne
|
|
$ExpectedRuntimeSignerThumbprint.ToUpperInvariant()) {
|
|
Throw-CampaignError 'runtime_cli_signature_invalid'
|
|
}
|
|
|
|
$runtimeIdentity = Invoke-TrustedCli $runtimeCli.Path @(
|
|
'version', '--correlation-id',
|
|
('campaign-runtime-' + [Guid]::NewGuid().ToString('N'))
|
|
)
|
|
if (-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') {
|
|
Throw-CampaignError 'runtime_cli_identity_invalid'
|
|
}
|
|
|
|
$verified = Invoke-TrustedCli $verifierCli.Path @(
|
|
'acceptance', 'verify-uat-authorization', '--input', $authorization.Path,
|
|
'--correlation-id', ('campaign-auth-' + [Guid]::NewGuid().ToString('N'))
|
|
)
|
|
$verifiedProperties = @(
|
|
'packageType', 'schemaVersion', 'sourceSha256', 'contentSha256',
|
|
'authorizationId', 'authorizationIdSha256', 'customerId',
|
|
'environmentId', 'environmentClass', 'erpScope',
|
|
'runtimeConfigurationSha256', 'customerProfileSha256',
|
|
'rolloutPolicySha256', 'sourceCommit', 'packageSha256',
|
|
'erpExecutable', 'runtimeCli', 'verifierCli', 'workflows', 'issuedAtUtc',
|
|
'expiresAtUtc', 'approvedBy', 'signatureVerified', 'uatAuthorized',
|
|
'productionReady', 'note'
|
|
)
|
|
if (-not (Test-ExactProperties $verified $verifiedProperties) -or
|
|
$verified.packageType -cne 'workflow_write_uat_authorization' -or
|
|
$verified.schemaVersion -cne '1.2' -or
|
|
[string]$verified.sourceSha256 -cne $authorizationSourceHash -or
|
|
-not (Test-ExactProperties $verified.runtimeCli @(
|
|
'fileName', 'version', 'sha256', 'signerThumbprint',
|
|
'requiresElevation', 'bridgeOnly', 'databaseDirectAccess',
|
|
'sessionSource')) -or
|
|
[string]$verified.runtimeCli.fileName -cne 'lserp-agent-cli.exe' -or
|
|
[string]$verified.runtimeCli.version -cne $ExpectedRuntimeCliVersion -or
|
|
[string]$verified.runtimeCli.sha256 -cne $runtimeCliHash -or
|
|
([string]$verified.runtimeCli.signerThumbprint).ToUpperInvariant() -cne
|
|
$runtimeSigner -or
|
|
$verified.runtimeCli.requiresElevation -ne $false -or
|
|
$verified.runtimeCli.bridgeOnly -ne $true -or
|
|
$verified.runtimeCli.databaseDirectAccess -ne $false -or
|
|
[string]$verified.runtimeCli.sessionSource -cne
|
|
'current_logged_in_erp_process' -or
|
|
[string]$verified.verifierCli.sha256 -cne $verifierCliHash -or
|
|
([string]$verified.verifierCli.signerThumbprint).ToUpperInvariant() -cne
|
|
$verifierSigner -or
|
|
$verified.verifierCli.requiresElevation -ne $true -or
|
|
$verified.environmentClass -cne 'recoverable_uat' -or
|
|
$verified.signatureVerified -ne $true -or
|
|
$verified.uatAuthorized -ne $true -or
|
|
$verified.productionReady -ne $false) {
|
|
Throw-CampaignError 'uat_authorization_contract_mismatch'
|
|
}
|
|
|
|
$authorization.Stream.Position = 0
|
|
$reader = New-Object IO.StreamReader($authorization.Stream, $utf8, $true, 4096, $true)
|
|
try { $authorizationText = $reader.ReadToEnd() } finally { $reader.Dispose() }
|
|
try { $authorizationJson = $authorizationText | ConvertFrom-Json }
|
|
catch { Throw-CampaignError 'uat_authorization_json_invalid' }
|
|
if (-not (Test-ExactProperties $authorizationJson @(
|
|
'schemaVersion', 'contentSha256', 'signatureAlgorithm',
|
|
'certificateThumbprint', 'signatureBase64', 'content')) -or
|
|
[string]$authorizationJson.schemaVersion -cne '1.2' -or
|
|
[string]$authorizationJson.contentSha256 -cne [string]$verified.contentSha256) {
|
|
Throw-CampaignError 'uat_authorization_json_invalid'
|
|
}
|
|
$content = $authorizationJson.content
|
|
if (-not (Test-ExactProperties $content @(
|
|
'packageType', 'authorizationId', 'customerId', 'environmentId',
|
|
'environmentClass', 'erpScope', 'runtimeConfigurationSha256',
|
|
'customerProfileSha256', 'rolloutPolicySha256', 'sourceCommit',
|
|
'packageSha256', 'erpExecutable', 'runtimeCli', 'verifierCli', 'safety',
|
|
'workflows', 'issuedAtUtc', 'expiresAtUtc', 'approvedBy', 'note')) -or
|
|
-not (Test-ExactProperties $content.erpScope @(
|
|
'accountBook', 'subSystemId', 'userId', 'userName',
|
|
'databaseScopeFingerprint')) -or
|
|
[string]$content.erpScope.databaseScopeFingerprint -cne
|
|
[string]$verified.erpScope.databaseScopeFingerprint -or
|
|
-not (Test-ExactProperties $content.safety @(
|
|
'databaseBackupVerified', 'restoreProcedureVerified',
|
|
'nonProductionEnvironmentVerified', 'productionUseProhibited',
|
|
'nativeConfirmationRequired', 'transactionAndAuditRequired',
|
|
'maximumPlanAttemptsPerCase', 'maximumExecuteAttemptsPerCase')) -or
|
|
$content.safety.databaseBackupVerified -ne $true -or
|
|
$content.safety.restoreProcedureVerified -ne $true -or
|
|
$content.safety.nonProductionEnvironmentVerified -ne $true -or
|
|
$content.safety.productionUseProhibited -ne $true -or
|
|
$content.safety.nativeConfirmationRequired -ne $true -or
|
|
$content.safety.transactionAndAuditRequired -ne $true -or
|
|
[int]$content.safety.maximumPlanAttemptsPerCase -ne 6 -or
|
|
[int]$content.safety.maximumExecuteAttemptsPerCase -ne 3) {
|
|
Throw-CampaignError 'uat_authorization_safety_invalid'
|
|
}
|
|
|
|
$workflowNames = @($verified.workflows | ForEach-Object { [string]$_.workflow })
|
|
if ($workflowNames.Count -lt 1 -or $workflowNames.Count -gt 2 -or
|
|
@($workflowNames | Select-Object -Unique).Count -ne $workflowNames.Count -or
|
|
@($workflowNames | Where-Object { $_ -cnotin @('purchase', 'leave') }).Count -ne 0) {
|
|
Throw-CampaignError 'uat_authorization_workflows_invalid'
|
|
}
|
|
$seenTokenHashes = @{}
|
|
$campaignWorkflows = New-Object System.Collections.Generic.List[object]
|
|
foreach ($workflowName in @('purchase', 'leave')) {
|
|
if ($workflowNames -cnotcontains $workflowName) { continue }
|
|
$expectedCases = if ($workflowName -eq 'purchase') { $purchaseCases } else { $leaveCases }
|
|
$sourceWorkflow = @($content.workflows | Where-Object {
|
|
[string]$_.workflow -ceq $workflowName
|
|
})
|
|
$verifiedWorkflow = @($verified.workflows | Where-Object {
|
|
[string]$_.workflow -ceq $workflowName
|
|
})
|
|
if ($sourceWorkflow.Count -ne 1 -or $verifiedWorkflow.Count -ne 1 -or
|
|
-not (Test-ExactProperties $sourceWorkflow[0] @(
|
|
'workflow', 'moduleCode', 'adapterId', 'adapterVersion', 'cases')) -or
|
|
[int]$verifiedWorkflow[0].caseCount -ne $expectedCases.Count -or
|
|
[string]$sourceWorkflow[0].moduleCode -cne [string]$verifiedWorkflow[0].moduleCode) {
|
|
Throw-CampaignError 'uat_authorization_workflow_contract_invalid'
|
|
}
|
|
$sourceCases = @($sourceWorkflow[0].cases)
|
|
if ($sourceCases.Count -ne $expectedCases.Count) {
|
|
Throw-CampaignError 'uat_authorization_case_coverage_invalid'
|
|
}
|
|
$campaignCases = New-Object System.Collections.Generic.List[object]
|
|
for ($index = 0; $index -lt $expectedCases.Count; $index++) {
|
|
$caseCode = $expectedCases[$index]
|
|
$sourceCase = $sourceCases[$index]
|
|
$commandName = Get-ExpectedCommand $caseCode
|
|
$catalogCase = $catalogCaseByCode[$caseCode]
|
|
$allowedCommands = @(Get-AllowedCommands $commandName)
|
|
if (-not (Test-ExactProperties $sourceCase @(
|
|
'caseCode', 'expectedCommandName', 'allowedCommands', 'tokenSha256')) -or
|
|
[string]$sourceCase.caseCode -cne $caseCode -or
|
|
[string]$sourceCase.expectedCommandName -cne $commandName -or
|
|
[string]$catalogCase.commandName -cne $commandName -or
|
|
-not (Test-ExactStringArray @($sourceCase.allowedCommands) $allowedCommands) -or
|
|
([string]$sourceCase.tokenSha256) -cnotmatch '^[a-f0-9]{64}$' -or
|
|
$seenTokenHashes.ContainsKey([string]$sourceCase.tokenSha256)) {
|
|
Throw-CampaignError 'uat_authorization_case_contract_invalid'
|
|
}
|
|
$seenTokenHashes[[string]$sourceCase.tokenSha256] = $true
|
|
$captureMode = if ($caseCode -in $derivedCases) {
|
|
'derived_audit'
|
|
} elseif ($caseCode -in $executeCases) {
|
|
'execute'
|
|
} else { 'plan_only' }
|
|
if ([string]$catalogCase.captureMode -cne $captureMode) {
|
|
Throw-CampaignError 'case_catalog_case_contract_invalid'
|
|
}
|
|
$dependency = Get-Dependency $caseCode
|
|
$auditOutput = if ($caseCode -eq 'purchase_unique_match_commit') {
|
|
'purchase_audit_correlated.json'
|
|
} elseif ($caseCode -eq 'leave_create_draft_commit') {
|
|
'leave_audit_correlated.json'
|
|
} else { $null }
|
|
$operatorStage = if ($captureMode -eq 'derived_audit') {
|
|
'derived_from_dependency'
|
|
} elseif ($caseCode -in $postPlanStagingCases) {
|
|
'post_plan_change_required'
|
|
} elseif ($caseCode.EndsWith('_transaction_rollback', [StringComparison]::Ordinal)) {
|
|
'controlled_failure_fixture_required'
|
|
} elseif ($null -ne $dependency) {
|
|
'relationship_fixture_required'
|
|
} else { 'case_fixture_required' }
|
|
$campaignCases.Add([pscustomobject][ordered]@{
|
|
sequence = $index + 1
|
|
caseCode = $caseCode
|
|
commandName = $commandName
|
|
captureMode = $captureMode
|
|
execute = ($captureMode -eq 'execute')
|
|
pauseAfterPlanForOperatorStaging = ($caseCode -in $postPlanStagingCases)
|
|
operatorStage = $operatorStage
|
|
dependencyCaseCode = $dependency
|
|
idempotencyPolicy = Get-IdempotencyPolicy $caseCode $captureMode
|
|
inputFile = if ($captureMode -eq 'derived_audit') {
|
|
$null
|
|
} else { 'private-input/' + $caseCode + '.json' }
|
|
evidenceFile = $caseCode + '.json'
|
|
correlatedAuditEvidenceFile = $auditOutput
|
|
requiresDbaReadOnlyObservation = $true
|
|
expectedBusinessMutation =
|
|
[string]$catalogCase.expectedMutationPolicy
|
|
})
|
|
}
|
|
$campaignWorkflows.Add([pscustomobject][ordered]@{
|
|
workflow = $workflowName
|
|
moduleCode = [string]$sourceWorkflow[0].moduleCode
|
|
caseCount = $expectedCases.Count
|
|
caseIndexFile = 'evidence/' + $workflowName + '-index.json'
|
|
assembledCasesFile = 'evidence/' + $workflowName + '-cases.json'
|
|
cases = @($campaignCases)
|
|
})
|
|
}
|
|
if ($campaignWorkflows.Count -ne $workflowNames.Count) {
|
|
Throw-CampaignError 'uat_authorization_workflows_invalid'
|
|
}
|
|
|
|
$campaignDirectory = New-RestrictedCampaignDirectory $OutputRoot $CampaignId
|
|
$privateInputDirectory = Join-Path $campaignDirectory 'private-input'
|
|
$evidenceDirectory = Join-Path $campaignDirectory 'evidence'
|
|
[IO.Directory]::CreateDirectory($privateInputDirectory) | Out-Null
|
|
[IO.Directory]::CreateDirectory($evidenceDirectory) | Out-Null
|
|
Assert-RestrictedDirectoryAcl $campaignDirectory
|
|
|
|
$createdAtUnixSeconds = ConvertTo-UnixSeconds ([DateTimeOffset]::UtcNow)
|
|
$manifestContent = [pscustomobject][ordered]@{
|
|
packageType = 'workflow_write_uat_campaign'
|
|
campaignId = $CampaignId
|
|
createdAtUnixSeconds = $createdAtUnixSeconds
|
|
caseCatalogSha256 = $caseCatalogHash
|
|
authorization = [pscustomobject][ordered]@{
|
|
sourceSha256 = [string]$verified.sourceSha256
|
|
contentSha256 = [string]$verified.contentSha256
|
|
authorizationIdSha256 = [string]$verified.authorizationIdSha256
|
|
customerId = [string]$verified.customerId
|
|
environmentId = [string]$verified.environmentId
|
|
environmentClass = 'recoverable_uat'
|
|
issuedAtUnixSeconds = ConvertTo-UnixSeconds $verified.issuedAtUtc
|
|
expiresAtUnixSeconds = ConvertTo-UnixSeconds $verified.expiresAtUtc
|
|
verifierCliSha256 = $verifierCliHash
|
|
verifierCliSignerThumbprint = $verifierSigner
|
|
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
|
runtimeCliSha256 = $runtimeCliHash
|
|
runtimeCliSignerThumbprint = $runtimeSigner
|
|
userIdSha256 = [string]$verified.erpScope.userIdSha256
|
|
userNameSha256 = [string]$verified.erpScope.userNameSha256
|
|
databaseScopeFingerprint =
|
|
[string]$verified.erpScope.databaseScopeFingerprint
|
|
}
|
|
safety = [pscustomobject][ordered]@{
|
|
productionUseProhibited = $true
|
|
automaticDatabaseWrites = $false
|
|
oneCaseAtATime = $true
|
|
operatorStagingRequired = $true
|
|
authorizationReverificationRequiredBeforeResume = $true
|
|
tokenMaterialIncluded = $false
|
|
idempotencyMaterialIncluded = $false
|
|
privateBusinessInputIncluded = $false
|
|
resumeStateDerivedOnlyFromEvidence = $true
|
|
}
|
|
directories = [pscustomobject][ordered]@{
|
|
privateInput = 'private-input'
|
|
evidence = 'evidence'
|
|
}
|
|
workflows = @($campaignWorkflows)
|
|
productionReady = $false
|
|
}
|
|
$manifestContentJson = $manifestContent | ConvertTo-Json -Depth 14 -Compress
|
|
$manifestContentSha256 = Get-Sha256Hex $utf8.GetBytes($manifestContentJson)
|
|
$manifest = [pscustomobject][ordered]@{
|
|
schemaVersion = '1.1'
|
|
contentSha256 = $manifestContentSha256
|
|
content = $manifestContent
|
|
}
|
|
Write-NewJson (Join-Path $campaignDirectory 'campaign.json') $manifest 16
|
|
foreach ($workflow in $campaignWorkflows) {
|
|
$indexDocument = [pscustomobject][ordered]@{
|
|
schemaVersion = '1.0'
|
|
workflow = [string]$workflow.workflow
|
|
caseFiles = @($workflow.cases | ForEach-Object { [string]$_.evidenceFile })
|
|
}
|
|
Write-NewJson `
|
|
(Join-Path $evidenceDirectory ([string]$workflow.workflow + '-index.json')) `
|
|
$indexDocument 5
|
|
}
|
|
$campaignCaseCount = 0
|
|
foreach ($workflow in $campaignWorkflows) {
|
|
$campaignCaseCount += @($workflow.cases).Count
|
|
}
|
|
|
|
[pscustomobject][ordered]@{
|
|
campaignFile = Join-Path $campaignDirectory 'campaign.json'
|
|
campaignId = $CampaignId
|
|
campaignContentSha256 = $manifestContentSha256
|
|
caseCatalogSha256 = $caseCatalogHash
|
|
workflowCount = $campaignWorkflows.Count
|
|
caseCount = $campaignCaseCount
|
|
authorizationSignatureVerified = $true
|
|
verifierCliSha256 = $verifierCliHash
|
|
runtimeCliVersion = $ExpectedRuntimeCliVersion
|
|
runtimeCliSha256 = $runtimeCliHash
|
|
runtimeCliSignerThumbprint = $runtimeSigner
|
|
automaticDatabaseWrites = $false
|
|
tokenMaterialIncluded = $false
|
|
readyForOneCaseAtATimeCapture = $true
|
|
productionReady = $false
|
|
} | ConvertTo-Json -Depth 5
|
|
}
|
|
catch {
|
|
if ($null -ne $campaignDirectory -and
|
|
[IO.Directory]::Exists($campaignDirectory)) {
|
|
try { [IO.Directory]::Delete($campaignDirectory, $true) } catch { }
|
|
}
|
|
throw
|
|
}
|
|
finally {
|
|
foreach ($lock in @($locks)) {
|
|
if ($null -ne $lock) { $lock.Dispose() }
|
|
}
|
|
}
|