Common/AssessmentDecisions.ps1
|
function Import-AssessmentDecisions { <# .SYNOPSIS Validates a tenant-scoped assessor sidecar without changing observations. #> [CmdletBinding()] param([string]$Path, [string]$TenantId) if (-not $Path) { return $null } $json = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop $schema = Join-Path -Path $PSScriptRoot -ChildPath '../schemas/assessment-decisions.schema.json' if (-not (Test-Json -Json $json -SchemaFile $schema -ErrorAction Stop)) { throw 'Invalid assessment decisions.' } $document = ConvertFrom-Json -InputObject $json if (-not $TenantId -or $document.tenantId -ne $TenantId) { throw 'Assessment decisions tenantId must match the collected tenant GUID.' } $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($decision in $document.decisions) { if (-not $keys.Add("$($decision.checkId)`n$($decision.setting)")) { throw 'Duplicate assessor decision scope.' } if ([datetimeoffset]$decision.expiresAt -le [datetimeoffset]$decision.approvedAt) { throw 'Decision expiry must follow approval.' } foreach ($field in @('setting', 'justification', 'approvedBy', 'evidence')) { if ([string]::IsNullOrWhiteSpace($decision.$field)) { throw "Decision $field must not be blank." } } } return $document } function Add-AssessmentDecisions { <# .SYNOPSIS Adds decision metadata; Status and CurrentValue always remain observations. .DESCRIPTION Matches a base check and exact setting, never a mutable sub-number alone. Accepted risks must match the original observed value. Attestations apply only to Review findings. Expired, future, ambiguous and changed evidence decisions stay visible but cannot remove findings from actionable work. #> [CmdletBinding()] param([AllowEmptyCollection()][object[]]$Findings, [object]$Document, [datetimeoffset]$AsOf = [datetimeoffset]::UtcNow) foreach ($finding in $Findings) { $baseId = $finding.CheckId -replace '\.\d+$', '' $scopedDecisions = @($Document.decisions | Where-Object { $_.checkId -eq $baseId -and $_.setting -ceq $finding.Setting }) $decision = $null if ($scopedDecisions.Count -eq 1) { $source = $scopedDecisions[0] $state = 'Active' $instances = @($Findings | Where-Object { ($_.CheckId -replace '\.\d+$', '') -eq $baseId -and $_.Setting -ceq $finding.Setting }) if ($instances.Count -ne 1) { $state = 'Ambiguous' } elseif ([datetimeoffset]$source.expiresAt -le $AsOf) { $state = 'Expired' } elseif ([datetimeoffset]$source.approvedAt -gt $AsOf) { $state = 'Future' } elseif ($source.type -eq 'ManualAttestation' -and $finding.Status -ne 'Review') { $state = 'Ineligible' } elseif ($source.type -eq 'AcceptedRisk' -and ($finding.Status -notin @('Fail', 'Warning') -or [string]$finding.CurrentValue -cne $source.observedValue)) { $state = 'EvidenceChanged' } $decision = $source | Select-Object * $decision | Add-Member -NotePropertyName state -NotePropertyValue $state -Force } $finding | Add-Member -NotePropertyName Decision -NotePropertyValue $decision -Force $finding | Add-Member -NotePropertyName Actionable -NotePropertyValue ([bool]($finding.Status -in @('Fail','Warning','Review') -and -not ($decision -and $decision.state -eq 'Active'))) -Force } } function Get-AssessmentCollectionState { <# .SYNOPSIS Summarizes collector completion separately from observed check scores. #> [CmdletBinding()] param([AllowEmptyCollection()][object[]]$Summary, [AllowEmptyCollection()][object[]]$Findings) $collectors = @($Summary | Select-Object Section, Collector, Status, Items, Error) $unavailable = @($Findings | Where-Object { $_.Status -in @('Unknown','Skipped','NotLicensed') }).Count $incomplete = @($collectors | Where-Object { $_.Status -ne 'Complete' -or $_.Error }).Count return [ordered]@{ state = if ($collectors.Count -eq 0) { 'Unspecified' } elseif ($incomplete -or $unavailable) { 'Incomplete' } else { 'Complete' } scope = 'Requested collectors only; completeness does not establish compliance.' unavailableFindings = $unavailable incompleteCollectors = $incomplete collectors = $collectors } } |