Public/Drift/Send-TBDriftNotification.ps1

function Send-TBDriftNotification {
    <#
    .SYNOPSIS
        Sends webhook notifications for newly detected configuration drift.
    .DESCRIPTION
        Checks the latest UTCM monitor result for one or more monitors and sends
        a webhook notification only when the active drift set for a monitor has
        changed since the last successful notification. This cmdlet is designed
        for unattended execution from runbooks, scheduled jobs, or CI/CD
        runners.
    .PARAMETER MonitorId
        Optional monitor IDs to scope notification checks. When omitted, all
        monitors are evaluated.
    .PARAMETER WebhookUrl
        The HTTPS webhook endpoint that receives the notification payload.
    .PARAMETER PayloadFormat
        Notification payload format:
        - TeamsAdaptiveCard: message payload suitable for Teams workflow/incoming webhook style endpoints
        - Generic: plain JSON payload for automation endpoints such as Logic Apps or custom services
    .PARAMETER StatePath
        Path to the JSON file used to suppress duplicate notifications for the
        same monitor result. Defaults to a per-user local state file.
    .PARAMETER MaxDrifts
        Maximum number of drift entries to include in the payload.
    .PARAMETER Force
        Sends the notification even if the latest monitor result was already
        recorded in the state file.
    .EXAMPLE
        Send-TBDriftNotification -WebhookUrl $env:TB_WEBHOOK_URL
    .EXAMPLE
        Send-TBDriftNotification -MonitorId $monitor.Id -WebhookUrl $env:TB_WEBHOOK_URL -StatePath './tb-notify-state.json'
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [Alias('Id')]
        [string[]]$MonitorId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$WebhookUrl,

        [Parameter()]
        [ValidateSet('TeamsAdaptiveCard', 'Generic')]
        [string]$PayloadFormat = 'TeamsAdaptiveCard',

        [Parameter()]
        [string]$StatePath = (Get-TBDefaultNotificationStatePath),

        [Parameter()]
        [ValidateRange(1, 50)]
        [int]$MaxDrifts = 10,

        [Parameter()]
        [switch]$Force
    )

    begin {
        $monitorQueue = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($id in @($MonitorId)) {
            if ($id -and -not $monitorQueue.Contains($id)) {
                $monitorQueue.Add($id)
            }
        }
    }

    end {
        $state = Get-TBNotificationState -Path $StatePath
        $targetLabel = Get-TBWebhookTargetLabel -Uri $WebhookUrl

        $monitors = if ($monitorQueue.Count -gt 0) {
            foreach ($id in $monitorQueue) {
                Get-TBMonitor -MonitorId $id
            }
        }
        else {
            @(Get-TBMonitor)
        }

        foreach ($monitor in @($monitors)) {
            $monitorName = if ($monitor.DisplayName) { $monitor.DisplayName } else { $monitor.Id }
            Write-TBLog -Message ('Evaluating drift notification state for monitor {0} ({1})' -f $monitorName, $monitor.Id)

            $latestResult = @(Get-TBMonitorResult -MonitorId $monitor.Id |
                Sort-Object -Property @{ Expression = {
                        if ($_.RunCompletionDateTime) {
                            [datetimeoffset]$_.RunCompletionDateTime
                        }
                        else {
                            [datetimeoffset]::MinValue
                        }
                    }
                } -Descending |
                Select-Object -First 1)

            if (-not $latestResult) {
                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $null
                    DriftCount         = 0
                    ActiveDriftCount   = 0
                    NotificationSent   = $false
                    Duplicate          = $false
                    Reason             = 'NoResults'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
                continue
            }

            if ($latestResult.RunStatus -ne 'successful') {
                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $latestResult.Id
                    DriftCount         = $latestResult.DriftsCount
                    ActiveDriftCount   = 0
                    NotificationSent   = $false
                    Duplicate          = $false
                    Reason             = 'LatestRunNotSuccessful'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
                continue
            }

            $stateItem = $state.Items[$monitor.Id]
            if ([int]$latestResult.DriftsCount -le 0) {
                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $latestResult.Id
                    DriftCount         = $latestResult.DriftsCount
                    ActiveDriftCount   = 0
                    NotificationSent   = $false
                    Duplicate          = $false
                    Reason             = 'NoDrift'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
                continue
            }

            $drifts = @(Get-TBDrift -MonitorId $monitor.Id)
            $activeDrifts = @($drifts | Where-Object { $_.Status -eq 'active' })
            if ($activeDrifts.Count -le 0) {
                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $latestResult.Id
                    DriftCount         = $latestResult.DriftsCount
                    ActiveDriftCount   = 0
                    NotificationSent   = $false
                    Duplicate          = $false
                    Reason             = 'NoActiveDrift'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
                continue
            }

            $activeDriftFingerprint = Get-TBActiveDriftFingerprint -Drifts $activeDrifts
            $isDuplicate = (
                -not $Force -and
                $stateItem -and
                $stateItem.LastActiveDriftFingerprint -and
                $stateItem.LastActiveDriftFingerprint -eq $activeDriftFingerprint
            )

            if ($isDuplicate) {
                Write-TBLog -Message ('Skipping notification for monitor {0}; active drift set has not changed since the previous notification' -f $monitorName)
                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $latestResult.Id
                    DriftCount         = $latestResult.DriftsCount
                    ActiveDriftCount   = $activeDrifts.Count
                    NotificationSent   = $false
                    Duplicate          = $true
                    Reason             = 'AlreadyNotified'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
                continue
            }

            $payload = New-TBDriftNotificationPayload -Monitor $monitor -LatestResult $latestResult -Drifts $drifts -ActiveDrifts $activeDrifts -PayloadFormat $PayloadFormat -MaxDrifts $MaxDrifts

            if ($PSCmdlet.ShouldProcess($targetLabel, ('Send drift notification for monitor {0}' -f $monitorName))) {
                Invoke-TBWebhookRequest -Uri $WebhookUrl -Payload $payload

                $state.Items[$monitor.Id] = @{
                    LastNotifiedResultId       = $latestResult.Id
                    LastRunCompletionDateTime  = $latestResult.RunCompletionDateTime
                    LastNotifiedAt             = (Get-Date).ToString('o')
                    LastPayloadFormat          = $PayloadFormat
                    LastActiveDriftFingerprint = $activeDriftFingerprint
                    LastActiveDriftCount       = $activeDrifts.Count
                }

                Save-TBNotificationState -Path $StatePath -State $state

                [PSCustomObject]@{
                    MonitorId          = $monitor.Id
                    MonitorDisplayName = $monitor.DisplayName
                    LatestResultId     = $latestResult.Id
                    DriftCount         = $latestResult.DriftsCount
                    ActiveDriftCount   = $activeDrifts.Count
                    NotificationSent   = $true
                    Duplicate          = $false
                    Reason             = 'Sent'
                    StatePath          = $StatePath
                    PayloadFormat      = $PayloadFormat
                }
            }
        }
    }
}

function Get-TBDefaultNotificationStatePath {
    [CmdletBinding()]
    param()

    $basePath = $env:LOCALAPPDATA
    if (-not $basePath) {
        $basePath = $env:HOME
    }
    if (-not $basePath) {
        $basePath = [System.IO.Path]::GetTempPath()
    }

    return (Join-Path $basePath 'TenantBaseline/notification-state.json')
}

function Get-TBNotificationState {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -Path $Path)) {
        return @{
            Version = 1
            Items   = @{}
        }
    }

    try {
        $raw = Get-Content -Path $Path -Raw -ErrorAction Stop
        if (-not $raw.Trim()) {
            return @{
                Version = 1
                Items   = @{}
            }
        }

        $parsed = ConvertFrom-Json -InputObject $raw -AsHashtable -Depth 20
        if (-not $parsed) {
            throw 'Notification state file is empty or invalid.'
        }

        if (-not $parsed.ContainsKey('Items') -or -not $parsed['Items']) {
            $parsed['Items'] = @{}
        }

        return $parsed
    }
    catch {
        Write-TBLog -Message ('Failed to read notification state from {0}: {1}' -f $Path, $_.Exception.Message) -Level 'Warning'
        return @{
            Version = 1
            Items   = @{}
        }
    }
}

function Save-TBNotificationState {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [hashtable]$State
    )

    $parentDir = Split-Path -Path $Path -Parent
    if ($parentDir -and -not (Test-Path -Path $parentDir)) {
        $null = New-Item -Path $parentDir -ItemType Directory -Force
    }

    $State | ConvertTo-Json -Depth 20 | Out-File -FilePath $Path -Encoding utf8 -Force
}

function New-TBDriftNotificationPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Monitor,

        [Parameter(Mandatory = $true)]
        [object]$LatestResult,

        [Parameter(Mandatory = $true)]
        [object[]]$Drifts,

        [Parameter(Mandatory = $true)]
        [object[]]$ActiveDrifts,

        [Parameter(Mandatory = $true)]
        [ValidateSet('TeamsAdaptiveCard', 'Generic')]
        [string]$PayloadFormat,

        [Parameter(Mandatory = $true)]
        [int]$MaxDrifts
    )

    $displayDrifts = @($ActiveDrifts | Select-Object -First $MaxDrifts)
    if ($displayDrifts.Count -eq 0) {
        $displayDrifts = @($Drifts | Select-Object -First $MaxDrifts)
    }

    $activeDriftCount = $ActiveDrifts.Count
    $fixedDriftCount = @($Drifts | Where-Object { $_.Status -eq 'fixed' }).Count
    $totalDriftedProperties = 0
    foreach ($drift in $Drifts) {
        $totalDriftedProperties += @($drift.DriftedProperties).Count
    }

    $summaryLine = '{0} active drift{1} detected for monitor "{2}"' -f $activeDriftCount, $(if ($activeDriftCount -eq 1) { '' } else { 's' }), $(if ($Monitor.DisplayName) { $Monitor.DisplayName } else { $Monitor.Id })
    if ($activeDriftCount -eq 0) {
        $summaryLine = '{0} drift result{1} detected for monitor "{2}"' -f $LatestResult.DriftsCount, $(if ([int]$LatestResult.DriftsCount -eq 1) { '' } else { 's' }), $(if ($Monitor.DisplayName) { $Monitor.DisplayName } else { $Monitor.Id })
    }

    $driftItems = foreach ($drift in $displayDrifts) {
        [PSCustomObject]@{
            Id                          = $drift.Id
            Status                      = $drift.Status
            ResourceType                = $drift.ResourceType
            BaselineResourceDisplayName = $drift.BaselineResourceDisplayName
            ResourceInstance            = Get-TBDriftInstanceLabel -Drift $drift
            FirstReportedDateTime       = $drift.FirstReportedDateTime
            DriftedProperties           = @($drift.DriftedProperties | ForEach-Object {
                    [PSCustomObject]@{
                        PropertyName = if ($_.PSObject.Properties['propertyName']) { $_.propertyName } else { $_['propertyName'] }
                        DesiredValue = if ($_.PSObject.Properties['desiredValue']) { "$($_.desiredValue)" } else { "$($_['desiredValue'])" }
                        CurrentValue = if ($_.PSObject.Properties['currentValue']) { "$($_.currentValue)" } else { "$($_['currentValue'])" }
                    }
                })
        }
    }

    $genericPayload = [ordered]@{
        Text        = $summaryLine
        GeneratedAt = (Get-Date).ToString('o')
        TenantId    = $LatestResult.TenantId
        Monitor     = [ordered]@{
            Id          = $Monitor.Id
            DisplayName = $Monitor.DisplayName
            Status      = $Monitor.Status
            Mode        = $Monitor.Mode
        }
        Result      = [ordered]@{
            Id                    = $LatestResult.Id
            RunStatus             = $LatestResult.RunStatus
            RunCompletionDateTime = $LatestResult.RunCompletionDateTime
            DriftsCount           = $LatestResult.DriftsCount
        }
        Summary     = [ordered]@{
            TotalDrifts            = $Drifts.Count
            ActiveDrifts           = $activeDriftCount
            FixedDrifts            = $fixedDriftCount
            TotalDriftedProperties = $totalDriftedProperties
        }
        Drifts      = $driftItems
    }

    if ($PayloadFormat -eq 'Generic') {
        return $genericPayload
    }

    $factSet = @(
        @{ title = 'Monitor'; value = $(if ($Monitor.DisplayName) { $Monitor.DisplayName } else { $Monitor.Id }) }
        @{ title = 'Latest Run'; value = "$($LatestResult.RunCompletionDateTime)" }
        @{ title = 'Active Drifts'; value = "$activeDriftCount" }
        @{ title = 'Total Drifted Properties'; value = "$totalDriftedProperties" }
    )

    $driftBlocks = @()
    foreach ($drift in $driftItems) {
        $propPreview = @($drift.DriftedProperties | Select-Object -First 3 | ForEach-Object {
                '{0}: desired={1}, current={2}' -f $_.PropertyName, $_.DesiredValue, $_.CurrentValue
            }) -join "`n"

        $driftBlocks += @{
            type   = 'TextBlock'
            wrap   = $true
            spacing = 'Medium'
            text   = ('{0} | {1} | {2}{3}' -f $drift.ResourceType, $drift.ResourceInstance, $drift.Status, $(if ($propPreview) { "`n$propPreview" } else { '' }))
        }
    }

    return @{
        type        = 'message'
        attachments = @(
            @{
                contentType = 'application/vnd.microsoft.card.adaptive'
                contentUrl  = $null
                content     = @{
                    '$schema' = 'http://adaptivecards.io/schemas/adaptive-card.json'
                    type      = 'AdaptiveCard'
                    version   = '1.4'
                    body      = @(
                        @{
                            type   = 'TextBlock'
                            size   = 'Large'
                            weight = 'Bolder'
                            text   = 'TenantBaseline Drift Alert'
                        },
                        @{
                            type = 'TextBlock'
                            wrap = $true
                            text = $summaryLine
                        },
                        @{
                            type  = 'FactSet'
                            facts = $factSet
                        }
                    ) + $driftBlocks
                }
            }
        )
    }
}

function Get-TBActiveDriftFingerprint {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object[]]$Drifts
    )

    $normalized = foreach ($drift in @($Drifts | Sort-Object -Property ResourceType, BaselineResourceDisplayName, FirstReportedDateTime, Id)) {
        $properties = foreach ($prop in @($drift.DriftedProperties | Sort-Object -Property @{
                    Expression = {
                        if ($_.PSObject.Properties['propertyName']) {
                            $_.propertyName
                        }
                        else {
                            $_['propertyName']
                        }
                    }
                })) {
            [ordered]@{
                PropertyName = if ($prop.PSObject.Properties['propertyName']) { $prop.propertyName } else { $prop['propertyName'] }
                DesiredValue = if ($prop.PSObject.Properties['desiredValue']) { "$($prop.desiredValue)" } else { "$($prop['desiredValue'])" }
                CurrentValue = if ($prop.PSObject.Properties['currentValue']) { "$($prop.currentValue)" } else { "$($prop['currentValue'])" }
            }
        }

        [ordered]@{
            Status           = $drift.Status
            ResourceType     = $drift.ResourceType
            BaselineName     = $drift.BaselineResourceDisplayName
            ResourceInstance = Get-TBDriftInstanceLabel -Drift $drift
            Properties       = @($properties)
        }
    }

    $json = $normalized | ConvertTo-Json -Depth 20 -Compress
    $hashBytes = [System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($json))
    return ([Convert]::ToHexString($hashBytes)).ToLowerInvariant()
}

function Get-TBDriftInstanceLabel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Drift
    )

    $identifier = $Drift.ResourceInstanceIdentifier
    if (-not $identifier) {
        return 'Unknown'
    }

    if ($identifier -is [hashtable]) {
        if ($identifier.ContainsKey('Identity') -and $identifier['Identity']) {
            return "$($identifier['Identity'])"
        }

        return ($identifier | ConvertTo-Json -Depth 5 -Compress)
    }

    if ($identifier.PSObject.Properties['Identity'] -and $identifier.Identity) {
        return "$($identifier.Identity)"
    }

    return ($identifier | ConvertTo-Json -Depth 5 -Compress)
}

function Get-TBWebhookTargetLabel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri
    )

    try {
        return ([uri]$Uri).Host
    }
    catch {
        return 'webhook endpoint'
    }
}

function Invoke-TBWebhookRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter(Mandatory = $true)]
        [object]$Payload,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$MaxRetries = 3
    )

    $body = $Payload | ConvertTo-Json -Depth 20
    $targetLabel = Get-TBWebhookTargetLabel -Uri $Uri
    $attempt = 0

    while ($true) {
        $attempt++
        Write-TBLog -Message ('Posting drift notification to {0} (attempt {1})' -f $targetLabel, $attempt)

        try {
            return Invoke-RestMethod -Uri $Uri -Method Post -Body $body -ContentType 'application/json'
        }
        catch {
            if ($attempt -ge $MaxRetries) {
                Write-TBLog -Message ('Webhook notification failed after {0} attempts: {1}' -f $attempt, $_.Exception.Message) -Level 'Error'
                throw
            }

            $delaySeconds = [math]::Pow(2, $attempt - 1)
            Write-TBLog -Message ('Webhook notification attempt {0} failed: {1}. Retrying in {2}s.' -f $attempt, $_.Exception.Message, $delaySeconds) -Level 'Warning'
            Start-Sleep -Seconds $delaySeconds
        }
    }
}