Public/Set-HardeningLensException.ps1

function Set-HardeningLensException {
    <#
    .SYNOPSIS
    Adds or transitions an exception in an existing governed exception register.
 
    .DESCRIPTION
    Adds Draft entries and performs forward-only Draft, Approved, Revoked lifecycle
    transitions. Approved entries require approval, ticket, expiry, and compensating
    control metadata. Changing an Approved entry resets it to Draft and removes its
    approval metadata so that the changed terms require an explicit new approval. The
    complete read-modify-write cycle is serialized and the file is replaced atomically.
 
    .PARAMETER Path
    Path to an existing valid Hardening Lens exception register.
 
    .PARAMETER Add
    Adds a new Draft entry. When Id is omitted, a unique ID is generated.
 
    .PARAMETER Id
    Exception ID to add or update.
 
    .PARAMETER Status
    Desired persisted status. Expired is an effective state of an Approved entry whose
    expiry date has passed and is not written as a separate schema status.
 
    .PARAMETER Revoke
    Transitions the selected Draft or Approved exception to Revoked. Revoke and Status
    cannot be supplied together.
 
    .PARAMETER Force
    Allows Add to replace an existing Draft with the same ID. Approved and Revoked
    entries are never replaced by Add.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Update', SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [switch]$Add,

        [Parameter(ParameterSetName = 'Add')]
        [Parameter(Mandatory, ParameterSetName = 'Update')]
        [ValidatePattern('^EXC-[A-Za-z0-9._-]+$')]
        [string]$Id,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string]$ControlId,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string]$Owner,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string]$Reason,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string]$Ticket,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [datetime]$Expires,

        [Parameter(Mandatory, ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string[]]$Target,

        [Parameter(ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string[]]$Baseline,

        [Parameter(ParameterSetName = 'Add')]
        [Parameter(ParameterSetName = 'Update')]
        [string[]]$CompensatingControl,

        [Parameter(ParameterSetName = 'Update')]
        [ValidateSet('Draft', 'Approved', 'Revoked')]
        [string]$Status,

        [Parameter(ParameterSetName = 'Update')]
        [switch]$Revoke,

        [Parameter(ParameterSetName = 'Update')]
        [string]$ApprovedBy,

        [Parameter(ParameterSetName = 'Update')]
        [datetime]$ApprovedOn,

        [switch]$Force
    )

    $fullPath = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
    $fileLock = Enter-HLFileLock -Path $fullPath
    try {
    $file = Read-HLExceptionFile -Path $fullPath
    $existingValidation = Test-HLExceptionDocument -Document $file.Document
    if (-not $existingValidation.IsValid) {
        throw ('Existing exception register is invalid: {0}' -f (@($existingValidation.Errors) -join ' '))
    }

    $document = Copy-HLObject -InputObject $file.Document
    $exceptions = @($document.exceptions)
    $approvalReset = $false
    if ($PSCmdlet.ParameterSetName -eq 'Add' -and [string]::IsNullOrWhiteSpace($Id)) {
        do {
            $Id = 'EXC-' + ([guid]::NewGuid().ToString('N').Substring(0, 12).ToUpperInvariant())
        } while (@($exceptions | Where-Object { [string]$_.id -ieq $Id }).Count -gt 0)
    }

    $matches = @($exceptions | Where-Object { [string]$_.id -ieq $Id })
    $previousStatus = $null
    if ($PSCmdlet.ParameterSetName -eq 'Add') {
        $newEntry = ConvertTo-HLDraftException -Id $Id -ControlId $ControlId -Owner $Owner -Reason $Reason -Ticket $Ticket -Expires $Expires -Target $Target -Baseline $Baseline -CompensatingControl $CompensatingControl
        if ($matches.Count -gt 0) {
            if (-not $Force) {
                throw "Exception '$Id' already exists. Use -Force only to replace an existing Draft."
            }
            if ($matches.Count -ne 1 -or [string]$matches[0].status -ne 'Draft') {
                throw "Exception '$Id' cannot be replaced because it is not a unique Draft entry."
            }
            $previousStatus = 'Draft'
            $exceptions = @($exceptions | Where-Object { [string]$_.id -ine $Id }) + @($newEntry)
        }
        else {
            $exceptions = @($exceptions) + @($newEntry)
        }
        $entry = $newEntry
    }
    else {
        if ($matches.Count -eq 0) {
            throw "Exception '$Id' was not found. Use -Add to create a Draft entry."
        }
        if ($matches.Count -gt 1) {
            throw "Exception '$Id' is not unique in the register."
        }

        if ($Revoke -and $PSBoundParameters.ContainsKey('Status')) {
            throw '-Revoke and -Status cannot be used together.'
        }

        $entry = $matches[0]
        $previousStatus = [string]$entry.status
        $approvalFingerprintBefore = if ($previousStatus -eq 'Approved') {
            Get-HLExceptionApprovalFingerprint -Exception $entry
        }
        else {
            $null
        }
        $desiredStatus = if ($Revoke) { 'Revoked' } elseif ($PSBoundParameters.ContainsKey('Status')) { $Status } else { $previousStatus }
        Assert-HLExceptionTransition -CurrentStatus $previousStatus -DesiredStatus $desiredStatus

        foreach ($propertyMap in @(
            @('ControlId', 'controlId'),
            @('Owner', 'owner'),
            @('Reason', 'reason'),
            @('Ticket', 'ticket'),
            @('Target', 'targets'),
            @('CompensatingControl', 'compensatingControls'),
            @('ApprovedBy', 'approvedBy')
        )) {
            if ($PSBoundParameters.ContainsKey($propertyMap[0])) {
                $value = $PSBoundParameters[$propertyMap[0]]
                if ($value -is [string]) {
                    $value = $value.Trim()
                }
                elseif ($value -is [System.Collections.IEnumerable]) {
                    $value = @($value)
                }
                Merge-HLNoteProperty -InputObject $entry -Name $propertyMap[1] -Value $value
            }
        }

        if ($PSBoundParameters.ContainsKey('Expires')) {
            Merge-HLNoteProperty -InputObject $entry -Name 'expires' -Value $Expires.ToString('yyyy-MM-dd')
        }
        if ($PSBoundParameters.ContainsKey('Baseline')) {
            if ($null -eq $Baseline -or @($Baseline).Count -eq 0) {
                [void]$entry.PSObject.Properties.Remove('baselines')
            }
            else {
                Merge-HLNoteProperty -InputObject $entry -Name 'baselines' -Value @($Baseline)
            }
        }
        if ($PSBoundParameters.ContainsKey('ApprovedOn')) {
            Merge-HLNoteProperty -InputObject $entry -Name 'approvedOn' -Value $ApprovedOn.ToString('yyyy-MM-dd')
        }
        elseif ($desiredStatus -eq 'Approved' -and $previousStatus -ne 'Approved') {
            Merge-HLNoteProperty -InputObject $entry -Name 'approvedOn' -Value (Get-Date).ToString('yyyy-MM-dd')
        }

        if ($previousStatus -eq 'Approved' -and $desiredStatus -eq 'Approved') {
            $approvalFingerprintAfter = Get-HLExceptionApprovalFingerprint -Exception $entry
            if ($approvalFingerprintBefore -cne $approvalFingerprintAfter) {
                $desiredStatus = 'Draft'
                [void]$entry.PSObject.Properties.Remove('approvedBy')
                [void]$entry.PSObject.Properties.Remove('approvedOn')
                $approvalReset = $true
            }
        }
        Merge-HLNoteProperty -InputObject $entry -Name 'status' -Value $desiredStatus

        if ($desiredStatus -eq 'Approved' -and (-not (Test-HLProperty -InputObject $entry -Name 'approvedBy') -or [string]::IsNullOrWhiteSpace([string]$entry.approvedBy))) {
            throw '-ApprovedBy is required when approving an exception.'
        }
    }

    Merge-HLNoteProperty -InputObject $document -Name 'exceptions' -Value @($exceptions)
    $updatedValidation = Test-HLExceptionDocument -Document $document
    if (-not $updatedValidation.IsValid) {
        throw ('Updated exception register is invalid: {0}' -f (@($updatedValidation.Errors) -join ' '))
    }

    $beforeJson = $file.Document | ConvertTo-Json -Depth 30 -Compress
    $afterJson = $document | ConvertTo-Json -Depth 30 -Compress
    $wouldChange = $beforeJson -cne $afterJson
    $changed = $false
    if ($wouldChange -and $PSCmdlet.ShouldProcess($fullPath, "Update exception '$Id'")) {
        Write-HLAtomicUtf8File -Path $fullPath -Content (($document | ConvertTo-Json -Depth 30) + [Environment]::NewLine)
        $changed = $true
    }

    return [pscustomobject][ordered]@{
        Path           = $fullPath
        Id             = $Id
        PreviousStatus = $previousStatus
        Status         = [string]$entry.status
        EffectiveStatus = Get-HLExceptionEffectiveStatus -Exception $entry
        ApprovalReset  = $approvalReset
        WouldChange    = $wouldChange
        Changed        = $changed
        Exception      = Copy-HLObject -InputObject $entry
    }
    }
    finally {
        Exit-HLFileLock -Lock $fileLock
    }
}