ReportCommands/ComplianceCore.ps1

#requires -Version 5.1

function Get-KeeperComplianceRestResponse {
    param(
        [Parameter(Mandatory = $true)]$Auth,
        [Parameter(Mandatory = $true)][string]$Endpoint,
        [Parameter()]$Request,
        [Parameter(Mandatory = $true)][type]$ResponseType
    )

    return $Auth.ExecuteAuthRest($Endpoint, $Request, $ResponseType).GetAwaiter().GetResult()
}

function Write-KeeperComplianceStatus {
    param(
        [Parameter(Mandatory = $true)][string]$Message
    )

    Write-Verbose -Message "[compliance] $Message"
}

function Set-KeeperComplianceLastSnapshotStatus {
    param(
        [Parameter()][bool]$FromCache = $false,
        [Parameter()][bool]$Incomplete = $false,
        [Parameter()][int]$PreliminaryUsersSkipped = 0,
        [Parameter()][int]$FullComplianceFailures = 0,
        [Parameter()][bool]$PrivilegeDeniedStoppedFullFetch = $false,
        [Parameter()][int]$RecordMetadataDecryptFailures = 0,
        [Parameter()][datetime]$BuiltAt = (Get-Date)
    )

    $script:ComplianceReportLastSnapshotStatus = [PSCustomObject][ordered]@{
        PSTypeName = 'KeeperComplianceSnapshotStatus'
        FromCache = $FromCache
        Incomplete = $Incomplete
        PreliminaryUsersSkipped = $PreliminaryUsersSkipped
        FullComplianceFailures = $FullComplianceFailures
        PrivilegeDeniedStoppedFullFetch = $PrivilegeDeniedStoppedFullFetch
        RecordMetadataDecryptFailures = $RecordMetadataDecryptFailures
        BuiltAt = $BuiltAt
    }
}

function ConvertTo-KeeperComplianceUid {
    param(
        [Parameter()]$ByteString
    )

    if (-not $ByteString -or $ByteString.IsEmpty) {
        return ''
    }

    return [KeeperSecurity.Utils.CryptoUtils]::Base64UrlEncode($ByteString.ToByteArray())
}

function Get-KeeperComplianceRecordData {
    param(
        [Parameter()]$EncryptedData,
        [Parameter()]$EcPrivateKey,
        [Parameter()]$Diagnostics,
        [Parameter()][string]$RecordUid,
        [Parameter()][ValidateSet('preliminary', 'full-compliance')][string]$Source = 'preliminary'
    )

    $result = [PSCustomObject]@{
        Title      = ''
        RecordType = ''
        Url        = ''
    }

    $ctx = if ($RecordUid) { "record=$RecordUid source=$Source" } else { "source=$Source" }

    if (-not $EncryptedData -or $EncryptedData.IsEmpty -or -not $EcPrivateKey) {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx skipped (no ciphertext or EC key)."
        return $result
    }

    $encBytes = $EncryptedData.ToByteArray()

    $jsonBytes = $null
    try {
        $jsonBytes = [KeeperSecurity.Utils.CryptoUtils]::DecryptEc($encBytes, $EcPrivateKey)
    }
    catch {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx DecryptEc failed: $($_.Exception.Message)"
        if ($Diagnostics) {
            $Diagnostics.RecordDataFailures = [int]$Diagnostics.RecordDataFailures + 1
        }
        return $result
    }

    if ($null -eq $jsonBytes -or $jsonBytes.Length -eq 0) {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx decrypt ok but plaintext length=0."
        return $result
    }

    $decodeOffset = 0
    if ($jsonBytes.Length -ge 3 -and $jsonBytes[0] -eq 0xEF -and $jsonBytes[1] -eq 0xBB -and $jsonBytes[2] -eq 0xBF) {
        $decodeOffset = 3
    }
    $jsonText = [System.Text.Encoding]::UTF8.GetString($jsonBytes, $decodeOffset, $jsonBytes.Length - $decodeOffset)
    if ($jsonText.Length -gt 0 -and [int][char]$jsonText[0] -eq 0xFEFF) {
        $jsonText = $jsonText.Substring(1)
    }
    $jsonText = $jsonText.Trim()
    if ([string]::IsNullOrWhiteSpace($jsonText)) {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx decrypt ok but JSON text empty after trim."
        return $result
    }

    $auditData = $null
    try {
        $auditData = $jsonText | ConvertFrom-Json
    }
    catch {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx ConvertFrom-Json failed: $($_.Exception.Message)"
        if ($Diagnostics) {
            $Diagnostics.RecordDataFailures = [int]$Diagnostics.RecordDataFailures + 1
        }
        return $result
    }

    if ($null -eq $auditData) {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx JSON root is null."
        return $result
    }
    if ($auditData -isnot [PSCustomObject]) {
        Write-KeeperComplianceStatus "Compliance metadata: $ctx JSON root is not an object (type=$($auditData.GetType().FullName))."
        return $result
    }

    foreach ($prop in $auditData.PSObject.Properties) {
        $n = [string]$prop.Name
        if ($n -ieq 'title') {
            $result.Title = [string]$prop.Value
        }
        elseif ($n -ieq 'record_type') {
            $result.RecordType = [string]$prop.Value
        }
        elseif ($n -ieq 'url') {
            $result.Url = [string]$prop.Value
        }
    }

    $titleLen = if ($result.Title) { $result.Title.Length } else { 0 }
    $urlLen = if ($result.Url) { $result.Url.Length } else { 0 }
    Write-KeeperComplianceStatus "Compliance metadata: $ctx extracted title_length=$titleLen record_type='$($result.RecordType)' url_length=$urlLen"

    return $result
}

function Merge-KeeperComplianceRecordFields {
    param(
        [Parameter(Mandatory = $true)]$RecordEntry,
        [Parameter(Mandatory = $true)]$RecordData
    )

    if ([string]::IsNullOrEmpty([string]$RecordEntry.Title) -and $RecordData.Title) {
        $RecordEntry.Title = [string]$RecordData.Title
    }
    if ([string]::IsNullOrEmpty([string]$RecordEntry.RecordType) -and $RecordData.RecordType) {
        $RecordEntry.RecordType = [string]$RecordData.RecordType
    }
    if ([string]::IsNullOrEmpty([string]$RecordEntry.Url) -and $RecordData.Url) {
        $RecordEntry.Url = [string]$RecordData.Url
    }
}

function Get-KeeperCompliancePrelimRequeueUserIds {
    param(
        [Parameter(Mandatory = $true)]$UserChunk,
        [Parameter(Mandatory = $true)]$SeenUserIds
    )

    $completeIds = [System.Collections.Generic.HashSet[long]]::new()
    if ($SeenUserIds.Count -gt 1) {
        foreach ($completedUserId in ($SeenUserIds | Select-Object -First ($SeenUserIds.Count - 1))) {
            $completeIds.Add([long]$completedUserId) | Out-Null
        }
    }
    return @($UserChunk | Where-Object { -not $completeIds.Contains([long]$_) })
}

function Add-KeeperComplianceUserQueueFront {
    param(
        [Parameter(Mandatory = $true)][System.Collections.Generic.Queue[long]]$Queue,
        [Parameter(Mandatory = $true)][long[]]$FrontIds
    )

    $newQ = [System.Collections.Generic.Queue[long]]::new()
    foreach ($id in $FrontIds) {
        $newQ.Enqueue($id)
    }
    while ($Queue.Count -gt 0) {
        $newQ.Enqueue($Queue.Dequeue())
    }
    return ,$newQ
}

$script:KeeperCompliancePermissionMasks = @(
    [PSCustomObject]@{ Mask = 1;  Name = 'owner' }
    [PSCustomObject]@{ Mask = 2;  Name = 'mask' }
    [PSCustomObject]@{ Mask = 4;  Name = 'edit' }
    [PSCustomObject]@{ Mask = 8;  Name = 'share' }
    [PSCustomObject]@{ Mask = 16; Name = 'share_admin' }
)
$script:KeeperCompliancePermissionShareAdmin = 16

function Get-KeeperCompliancePermissionText {
    param(
        [Parameter(Mandatory = $true)][int]$PermissionBits
    )

    $permissions = @()
    foreach ($permissionMask in $script:KeeperCompliancePermissionMasks) {
        if (($PermissionBits -band [int]$permissionMask.Mask) -ne 0) {
            $permissions += [string]$permissionMask.Name
        }
    }

    if ($permissions.Count -eq 0) {
        $permissions += 'read-only'
    }

    return ($permissions -join ',')
}

function Add-KeeperCompliancePermissionBits {
    param(
        [Parameter(Mandatory = $true)]$PermissionLookup,
        [Parameter(Mandatory = $true)][long]$UserUid,
        [Parameter(Mandatory = $true)][int]$PermissionBits
    )

    $currentBits = 0
    if ($PermissionLookup.ContainsKey($UserUid)) {
        $currentBits = [int]$PermissionLookup[$UserUid]
    }
    $PermissionLookup[$UserUid] = ($currentBits -bor $PermissionBits)
}

function Ensure-KeeperComplianceRecordEntry {
    param(
        [Parameter(Mandatory = $true)]$Snapshot,
        [Parameter(Mandatory = $true)][string]$RecordUid,
        [Parameter()][bool]$Shared = $false
    )

    if (-not $Snapshot.Records.ContainsKey($RecordUid)) {
        $Snapshot.Records[$RecordUid] = [PSCustomObject]@{
            Uid              = $RecordUid
            Title            = ''
            RecordType       = ''
            Url              = ''
            Shared           = $Shared
            InTrash          = $false
            UserPermissions  = @{}
            SharedFolderUids = [System.Collections.Generic.HashSet[string]]::new()
        }
    }
    elseif ($Shared -and -not $Snapshot.Records[$RecordUid].Shared) {
        $Snapshot.Records[$RecordUid].Shared = $true
    }

    return $Snapshot.Records[$RecordUid]
}

function Ensure-KeeperComplianceSharedFolderEntry {
    param(
        [Parameter(Mandatory = $true)]$Snapshot,
        [Parameter(Mandatory = $true)][string]$SharedFolderUid
    )

    if (-not $Snapshot.SharedFolders.ContainsKey($SharedFolderUid)) {
        $Snapshot.SharedFolders[$SharedFolderUid] = [PSCustomObject]@{
            Uid               = $SharedFolderUid
            Users             = [System.Collections.Generic.HashSet[long]]::new()
            Teams             = [System.Collections.Generic.HashSet[string]]::new()
            RecordPermissions = @{}
        }
    }

    return $Snapshot.SharedFolders[$SharedFolderUid]
}

function Add-KeeperCompliancePermissionByEmail {
    param(
        [Parameter(Mandatory = $true)]$PermissionLookup,
        [Parameter()][string]$Email,
        [Parameter(Mandatory = $true)][int]$PermissionBits
    )

    if (-not $Email) {
        return
    }

    $existingBits = 0
    if ($PermissionLookup.ContainsKey($Email)) {
        $existingBits = [int]$PermissionLookup[$Email]
    }
    $PermissionLookup[$Email] = ($existingBits -bor $PermissionBits)
}

function Add-KeeperCompliancePermissionByUserUid {
    param(
        [Parameter(Mandatory = $true)]$Snapshot,
        [Parameter(Mandatory = $true)]$PermissionLookup,
        [Parameter(Mandatory = $true)][long]$TargetUid,
        [Parameter(Mandatory = $true)][int]$PermissionBits
    )

    if (-not $Snapshot.Users.ContainsKey($TargetUid)) {
        return
    }

    Add-KeeperCompliancePermissionByEmail -PermissionLookup $PermissionLookup `
        -Email ([string]$Snapshot.Users[$TargetUid].Email) -PermissionBits $PermissionBits
}

function Write-KeeperReportOutput {
    param(
        [Parameter(Mandatory = $true)]$Rows,
        [Parameter()]$DisplayRows,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string]$Format = 'table',
        [Parameter()][string]$Output,
        [Parameter()][int]$JsonDepth = 6,
        [Parameter()][string[]]$TableColumns
    )

    if ($null -eq $DisplayRows) {
        $DisplayRows = $Rows
    }

    if ($Output -and $Format -ne 'table') {
        $outPath = $Output
        switch ($Format) {
            'json' { Set-Content -Path $outPath -Value ($DisplayRows | ConvertTo-Json -Depth $JsonDepth) -Encoding utf8 }
            'csv'  { $DisplayRows | Export-Csv -Path $outPath -NoTypeInformation -Encoding utf8 }
        }
        Write-Host "Report exported to $outPath ($($Rows.Count) row(s) found)"
        return
    }

    switch ($Format) {
        'json' { $DisplayRows | ConvertTo-Json -Depth $JsonDepth }
        'csv'  { $DisplayRows | ConvertTo-Csv -NoTypeInformation }
        default {
            Write-Host ""
            $resolvedTableColumns = @(
                @($TableColumns) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
            )
            if ($resolvedTableColumns.Count -eq 0 -and $DisplayRows.Count -gt 0) {
                $resolvedTableColumns = @($DisplayRows[0].PSObject.Properties.Name)
            }
            if ($resolvedTableColumns.Count -gt 0) {
                $DisplayRows | Format-Table -Property $resolvedTableColumns -AutoSize
            }
            else {
                $DisplayRows | Format-Table -AutoSize
            }
        }
    }
}

function Resolve-KeeperComplianceNode {
    param(
        [Parameter(Mandatory = $true)]$Node,
        [Parameter()][string]$Context = 'compliance report'
    )

    try {
        return (resolveSingleNode $Node)
    }
    catch {
        $message = [string]$_.Exception.Message
        if ($message.IndexOf('not found', [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
            Write-Error -Message "Cannot resolve node `"$Node`" for $Context. Use Get-KeeperEnterpriseNode or kein to list valid node IDs and names." -ErrorAction Stop
        }
        if ($message.IndexOf('not unique', [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
            Write-Error -Message "Node name `"$Node`" is ambiguous for $Context. Use the numeric node ID instead. Run Get-KeeperEnterpriseNode or kein to find the exact ID." -ErrorAction Stop
        }
        throw
    }
}

function Update-KeeperComplianceAnonymousUsers {
    param(
        [Parameter(Mandatory = $true)]$Response,
        [Parameter(Mandatory = $true)][long]$AnonymousSeed
    )

    $anonymousUserIds = @{}
    $nextSeed = $AnonymousSeed

    foreach ($userProfile in $Response.UserProfiles) {
        $userId = [long]$userProfile.EnterpriseUserId
        if (($userId -shr 32) -ne 0) {
            continue
        }

        $newUserId = $userId + $nextSeed
        $anonymousUserIds[$userId] = $newUserId
        $userProfile.EnterpriseUserId = $newUserId
        $nextSeed = $newUserId
    }

    foreach ($userRecord in $Response.UserRecords) {
        $userId = [long]$userRecord.EnterpriseUserId
        if ($anonymousUserIds.ContainsKey($userId)) {
            $userRecord.EnterpriseUserId = [long]$anonymousUserIds[$userId]
        }
    }

    foreach ($sharedFolderUser in $Response.SharedFolderUsers) {
        for ($i = 0; $i -lt $sharedFolderUser.EnterpriseUserIds.Count; $i++) {
            $userId = [long]$sharedFolderUser.EnterpriseUserIds[$i]
            if ($anonymousUserIds.ContainsKey($userId)) {
                $sharedFolderUser.EnterpriseUserIds[$i] = [long]$anonymousUserIds[$userId]
            }
        }
    }

    return $nextSeed
}

function Merge-KeeperComplianceResponse {
    param(
        [Parameter(Mandatory = $true)]$Snapshot,
        [Parameter(Mandatory = $true)]$Response
    )

    foreach ($userProfile in $Response.UserProfiles) {
        $userUid = [long]$userProfile.EnterpriseUserId
        if (-not $Snapshot.Users.ContainsKey($userUid)) {
            $Snapshot.Users[$userUid] = [PSCustomObject]@{
                UserUid  = $userUid
                Email    = [string]$userProfile.Email
                FullName = [string]$userProfile.FullName
                JobTitle = [string]$userProfile.JobTitle
                NodeId   = 0L
            }
            continue
        }

        if ([string]::IsNullOrEmpty([string]$Snapshot.Users[$userUid].Email) -and $userProfile.Email) {
            $Snapshot.Users[$userUid].Email = [string]$userProfile.Email
        }
        if ([string]::IsNullOrEmpty([string]$Snapshot.Users[$userUid].FullName) -and $userProfile.FullName) {
            $Snapshot.Users[$userUid].FullName = [string]$userProfile.FullName
        }
        if ($userProfile.JobTitle) {
            $Snapshot.Users[$userUid].JobTitle = [string]$userProfile.JobTitle
        }
    }

    foreach ($auditRecord in $Response.AuditRecords) {
        $recordUid = ConvertTo-KeeperComplianceUid -ByteString $auditRecord.RecordUid
        if (-not $recordUid) {
            continue
        }

        $recordEntry = Ensure-KeeperComplianceRecordEntry -Snapshot $Snapshot -RecordUid $recordUid

        $recordData = Get-KeeperComplianceRecordData -EncryptedData $auditRecord.AuditData -EcPrivateKey $Snapshot.EcPrivateKey `
            -Diagnostics $Snapshot.Diagnostics -RecordUid $recordUid -Source 'full-compliance'
        Merge-KeeperComplianceRecordFields -RecordEntry $recordEntry -RecordData $recordData

        $recordEntry.InTrash = [bool]$auditRecord.InTrash
    }

    foreach ($auditTeamUser in $Response.AuditTeamUsers) {
        $teamUid = ConvertTo-KeeperComplianceUid -ByteString $auditTeamUser.TeamUid
        if (-not $teamUid) {
            continue
        }

        if (-not $Snapshot.Teams.ContainsKey($teamUid)) {
            $Snapshot.Teams[$teamUid] = [PSCustomObject]@{
                Uid   = $teamUid
                Users = [System.Collections.Generic.HashSet[long]]::new()
            }
        }

        foreach ($userUid in $auditTeamUser.EnterpriseUserIds) {
            $Snapshot.Teams[$teamUid].Users.Add([long]$userUid) | Out-Null
        }
    }

    foreach ($sharedFolderRecord in $Response.SharedFolderRecords) {
        $sharedFolderUid = ConvertTo-KeeperComplianceUid -ByteString $sharedFolderRecord.SharedFolderUid
        if (-not $sharedFolderUid) {
            continue
        }

        $sharedFolderEntry = Ensure-KeeperComplianceSharedFolderEntry -Snapshot $Snapshot -SharedFolderUid $sharedFolderUid

        foreach ($recordPermission in $sharedFolderRecord.RecordPermissions) {
            $recordUid = ConvertTo-KeeperComplianceUid -ByteString $recordPermission.RecordUid
            if (-not $recordUid) {
                continue
            }

            $existingBits = 0
            if ($sharedFolderEntry.RecordPermissions.ContainsKey($recordUid)) {
                $existingBits = [int]$sharedFolderEntry.RecordPermissions[$recordUid]
            }
            $sharedFolderEntry.RecordPermissions[$recordUid] = ($existingBits -bor [int]$recordPermission.PermissionBits)

            $recordEntry = Ensure-KeeperComplianceRecordEntry -Snapshot $Snapshot -RecordUid $recordUid -Shared:$true
            $recordEntry.SharedFolderUids.Add($sharedFolderUid) | Out-Null
        }

        foreach ($shareAdminRecord in $sharedFolderRecord.ShareAdminRecords) {
            foreach ($recordPermissionIndex in $shareAdminRecord.RecordPermissionIndexes) {
                if ($recordPermissionIndex -lt 0 -or $recordPermissionIndex -ge $sharedFolderRecord.RecordPermissions.Count) {
                    continue
                }

                $recordPermission = $sharedFolderRecord.RecordPermissions[$recordPermissionIndex]
                $recordUid = ConvertTo-KeeperComplianceUid -ByteString $recordPermission.RecordUid
                if (-not $recordUid -or -not $Snapshot.Records.ContainsKey($recordUid)) {
                    continue
                }

                Add-KeeperCompliancePermissionBits -PermissionLookup $Snapshot.Records[$recordUid].UserPermissions `
                    -UserUid ([long]$shareAdminRecord.EnterpriseUserId) -PermissionBits $script:KeeperCompliancePermissionShareAdmin
            }
        }
    }

    foreach ($userRecord in $Response.UserRecords) {
        $userUid = [long]$userRecord.EnterpriseUserId
        foreach ($recordPermission in $userRecord.RecordPermissions) {
            $recordUid = ConvertTo-KeeperComplianceUid -ByteString $recordPermission.RecordUid
            if (-not $recordUid -or -not $Snapshot.Records.ContainsKey($recordUid)) {
                continue
            }

            Add-KeeperCompliancePermissionBits -PermissionLookup $Snapshot.Records[$recordUid].UserPermissions `
                -UserUid $userUid -PermissionBits ([int]$recordPermission.PermissionBits)
        }
    }

    foreach ($sharedFolderUser in $Response.SharedFolderUsers) {
        $sharedFolderUid = ConvertTo-KeeperComplianceUid -ByteString $sharedFolderUser.SharedFolderUid
        if (-not $sharedFolderUid) {
            continue
        }

        $sharedFolderEntry = Ensure-KeeperComplianceSharedFolderEntry -Snapshot $Snapshot -SharedFolderUid $sharedFolderUid

        foreach ($userUid in $sharedFolderUser.EnterpriseUserIds) {
            $sharedFolderEntry.Users.Add([long]$userUid) | Out-Null
        }
    }

    foreach ($sharedFolderTeam in $Response.SharedFolderTeams) {
        $sharedFolderUid = ConvertTo-KeeperComplianceUid -ByteString $sharedFolderTeam.SharedFolderUid
        if (-not $sharedFolderUid) {
            continue
        }

        $sharedFolderEntry = Ensure-KeeperComplianceSharedFolderEntry -Snapshot $Snapshot -SharedFolderUid $sharedFolderUid

        foreach ($teamUidBytes in $sharedFolderTeam.TeamUids) {
            $teamUid = ConvertTo-KeeperComplianceUid -ByteString $teamUidBytes
            if ($teamUid) {
                $sharedFolderEntry.Teams.Add([string]$teamUid) | Out-Null
            }
        }
    }
}

function Get-KeeperComplianceEnterpriseNodeSubtreeIds {
    param(
        [Parameter(Mandatory = $true)]$EnterpriseData,
        [Parameter(Mandatory = $true)][long]$RootNodeId
    )

    if ($RootNodeId -le 0) {
        return $null
    }

    $subnodes = @{}
    foreach ($n in $EnterpriseData.Nodes) {
        $parentId = [long]$n.ParentNodeId
        $childId = [long]$n.Id
        if ($parentId -gt 0) {
            if (-not $subnodes.ContainsKey($parentId)) {
                $subnodes[$parentId] = [System.Collections.Generic.List[long]]::new()
            }
            $subnodes[$parentId].Add($childId) | Out-Null
        }
    }

    $set = [System.Collections.Generic.HashSet[long]]::new()
    $queue = [System.Collections.Generic.Queue[long]]::new()
    $queue.Enqueue($RootNodeId) | Out-Null
    while ($queue.Count -gt 0) {
        $nid = $queue.Dequeue()
        [void]$set.Add($nid)
        if ($subnodes.ContainsKey($nid)) {
            foreach ($c in $subnodes[$nid]) {
                $queue.Enqueue($c) | Out-Null
            }
        }
    }

    $lookup = @{}
    foreach ($nid in $set) {
        $lookup["$([long]$nid)"] = $true
    }
    return $lookup
}

function Test-KeeperComplianceHasNonEmptyStringList {
    param(
        [Parameter()][AllowNull()][string[]]$Strings
    )

    if ($null -eq $Strings) {
        return $false
    }
    foreach ($s in $Strings) {
        if (-not [string]::IsNullOrWhiteSpace([string]$s)) {
            return $true
        }
    }
    return $false
}

function Test-KeeperComplianceHasNodeFilter {
    param(
        [Parameter()][AllowNull()][string]$Node
    )

    return -not [string]::IsNullOrWhiteSpace($Node)
}

function Resolve-KeeperComplianceFetchOwnerIds {
    param(
        [Parameter()][string[]]$Username,
        [Parameter()][string[]]$Team,
        [Parameter()][string]$Node
    )

    $enterprise = getEnterprise
    $enterpriseData = $enterprise.enterpriseData
    $candidateUserIds = [System.Collections.Generic.HashSet[long]]::new()
    $hasPrefilter = $false
    $hasUsernameFilter = Test-KeeperComplianceHasNonEmptyStringList -Strings $Username
    $hasTeamFilter = Test-KeeperComplianceHasNonEmptyStringList -Strings $Team

    if ($hasUsernameFilter) {
        $hasPrefilter = $true
        $lookup = @{}
        foreach ($value in $Username) {
            if (-not [string]::IsNullOrWhiteSpace([string]$value)) {
                $lookup[$value.ToLowerInvariant()] = $true
            }
        }
        foreach ($enterpriseUser in $enterpriseData.Users) {
            if ($enterpriseUser.Email -and $lookup.ContainsKey(([string]$enterpriseUser.Email).ToLowerInvariant())) {
                $candidateUserIds.Add([long]$enterpriseUser.Id) | Out-Null
            }
        }
    }

    if ($hasTeamFilter) {
        $hasPrefilter = $true
        foreach ($teamRef in $Team) {
            if ([string]::IsNullOrWhiteSpace([string]$teamRef)) {
                continue
            }
            $resolvedTeam = Get-KeeperTeamByNameOrUid -EnterpriseData $enterpriseData -TeamInput $teamRef
            if (-not $resolvedTeam) {
                Write-Warning "No enterprise team matched '$teamRef' for compliance owner pre-filter."
                continue
            }
            foreach ($userUid in $enterpriseData.GetUsersForTeam($resolvedTeam.Uid)) {
                $candidateUserIds.Add([long]$userUid) | Out-Null
            }
        }
    }

    if (Test-KeeperComplianceHasNodeFilter -Node $Node) {
        $hasPrefilter = $true
        $nodeInput = $Node.Trim()
        $resolvedNode = Resolve-KeeperComplianceNode -Node $nodeInput -Context 'compliance owner pre-filter'
        $targetNodeId = [long]$resolvedNode.Id
        $rootNodeId = [long]$enterpriseData.RootNode.Id
        if ($targetNodeId -eq $rootNodeId) {
            if ($candidateUserIds.Count -eq 0 -and -not $hasUsernameFilter -and -not $hasTeamFilter) {
                foreach ($enterpriseUser in $enterpriseData.Users) {
                    $candidateUserIds.Add([long]$enterpriseUser.Id) | Out-Null
                }
            }
        }
        else {
            $nodeMatchedUserIds = [System.Collections.Generic.HashSet[long]]::new()
            foreach ($enterpriseUser in $enterpriseData.Users) {
                $userNodeId = [long]$enterpriseUser.ParentNodeId
                if ($userNodeId -le 0) {
                    $userNodeId = $rootNodeId
                }
                if ($userNodeId -eq $targetNodeId) {
                    $nodeMatchedUserIds.Add([long]$enterpriseUser.Id) | Out-Null
                }
            }

            if ($candidateUserIds.Count -eq 0 -and -not $hasUsernameFilter -and -not $hasTeamFilter) {
                foreach ($userUid in $nodeMatchedUserIds) {
                    $candidateUserIds.Add([long]$userUid) | Out-Null
                }
            }
            else {
                $filteredUserIds = [System.Collections.Generic.HashSet[long]]::new()
                foreach ($userUid in $candidateUserIds) {
                    if ($nodeMatchedUserIds.Contains([long]$userUid)) {
                        $filteredUserIds.Add([long]$userUid) | Out-Null
                    }
                }
                $candidateUserIds = $filteredUserIds
            }
        }
    }

    if (-not $hasPrefilter) {
        return $null
    }

    return @(
        $candidateUserIds |
            Where-Object {
                $enterpriseUser = $null
                [bool]($enterpriseData.TryGetUserById([long]$_, [ref]$enterpriseUser) -and $enterpriseUser)
            } |
            Sort-Object
    )
}

function Get-KeeperComplianceDiskCacheRoot {
    return [System.IO.Path]::Combine(
        [Environment]::GetFolderPath('UserProfile'),
        '.keeper',
        'powercommander',
        'compliance_cache'
    )
}

function Get-KeeperComplianceSqliteDbPath {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    $server = [string]$Auth.Endpoint.Server
    if ([string]::IsNullOrWhiteSpace($server)) {
        $server = 'keepersecurity.com'
    }
    $safeServer = [System.Text.RegularExpressions.Regex]::Replace($server, '[^\w\-\.]', '_')
    $entId = 0L
    if ($Enterprise.enterpriseData -and $Enterprise.enterpriseData.EnterpriseLicense) {
        $entId = [long]$Enterprise.enterpriseData.EnterpriseLicense.EnterpriseLicenseId
    }
    $mc = 0
    if ($Script:Context.ManagedCompanyId) {
        $mc = [int]$Script:Context.ManagedCompanyId
    }
    $suffix = if ($mc -gt 0) { "_mc$mc" } else { '' }
    $cacheRoot = Get-KeeperComplianceDiskCacheRoot
    $serverDir = [System.IO.Path]::Combine($cacheRoot, $safeServer)
    if (-not (Test-Path -LiteralPath $serverDir)) {
        [void][System.IO.Directory]::CreateDirectory($serverDir)
    }
    return [System.IO.Path]::Combine($serverDir, "compliance_${entId}${suffix}.db")
}

function Get-KeeperComplianceSqliteStorage {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    $dbPath = Get-KeeperComplianceSqliteDbPath -Enterprise $Enterprise -Auth $Auth
    if ($script:ComplianceSqliteStorage -and $script:ComplianceSqliteDbPath -eq $dbPath) {
        return $script:ComplianceSqliteStorage
    }

    $script:ComplianceSqliteStorage = $null
    $script:ComplianceSqliteDbPath = $null

    $connectionString = "Data Source=$dbPath;Pooling=True;"
    try {
        $storage = Get-SqliteComplianceStorageFromHelper -ConnectionString $connectionString
        $script:ComplianceSqliteStorage = $storage
        $script:ComplianceSqliteDbPath = $dbPath
        return $storage
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to initialize SQLite compliance storage: $($_.Exception.Message)"
        return $null
    }
}

function Save-KeeperComplianceSnapshotToSqlite {
    param(
        [Parameter(Mandatory = $true)][string]$CacheKey,
        [Parameter(Mandatory = $true)]$Snapshot,
        [Parameter(Mandatory = $true)][bool]$Incomplete,
        [Parameter()][bool]$SharedOnly = $false,
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return }

        $storage.ClearNonAgingData()

        $nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

        $existingMeta = $null
        try { $existingMeta = @($storage.Metadata.GetAll()) | Select-Object -First 1 } catch { }

        $meta = New-Object KeeperSecurity.Compliance.ComplianceMetadata
        $meta.AccountUid = $CacheKey
        $meta.PrelimDataLastUpdate = $nowEpoch
        $meta.ComplianceDataLastUpdate = $nowEpoch
        $meta.SharedRecordsOnly = $SharedOnly
        if ($existingMeta) {
            $meta.RecordsDated = $existingMeta.RecordsDated
            $meta.LastPwAudit = $existingMeta.LastPwAudit
        }
        $storage.Metadata.Store($meta)

        $userEntities = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceUser]]::new()
        foreach ($userUid in $Snapshot.Users.Keys) {
            $u = $Snapshot.Users[$userUid]
            $cu = New-Object KeeperSecurity.Compliance.ComplianceUser
            $cu.UserUid = [long]$userUid
            $cu.Email = [System.Text.Encoding]::UTF8.GetBytes([string]$u.Email)
            $cu.Status = 0
            $cu.JobTitle = if ($u.JobTitle) { [System.Text.Encoding]::UTF8.GetBytes([string]$u.JobTitle) } else { [byte[]]@() }
            $cu.FullName = if ($u.FullName) { [System.Text.Encoding]::UTF8.GetBytes([string]$u.FullName) } else { [byte[]]@() }
            $cu.NodeId = if ($u.NodeId) { [long]$u.NodeId } else { 0L }
            $cu.LastRefreshed = $nowEpoch
            $cu.LastComplianceRefreshed = $nowEpoch
            $cu.LastAgingRefreshed = 0L
            $userEntities.Add($cu)
        }
        if ($userEntities.Count -gt 0) {
            $storage.Users.PutEntities($userEntities)
        }

        $recordEntities = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceRecord]]::new()
        foreach ($recUid in $Snapshot.Records.Keys) {
            $r = $Snapshot.Records[$recUid]
            $cr = New-Object KeeperSecurity.Compliance.ComplianceRecord
            $cr.RecordUid = [string]$recUid
            $cr.RecordUidBytes = [KeeperSecurity.Utils.CryptoUtils]::Base64UrlDecode([string]$recUid)
            $titleJson = @{ title = [string]$r.Title; type = [string]$r.RecordType; url = [string]$r.Url } | ConvertTo-Json -Compress
            $cr.EncryptedData = [System.Text.Encoding]::UTF8.GetBytes($titleJson)
            $cr.Shared = [bool]$r.Shared
            $cr.InTrash = [bool]$r.InTrash
            $cr.HasAttachments = $false
            $cr.LastComplianceRefreshed = $nowEpoch
            $recordEntities.Add($cr)
        }
        if ($recordEntities.Count -gt 0) {
            $storage.Records.PutEntities($recordEntities)
        }

        $userRecordLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceUserRecordLink]]::new()
        foreach ($ownerUid in $Snapshot.OwnedRecordsByUser.Keys) {
            foreach ($recUid in $Snapshot.OwnedRecordsByUser[$ownerUid]) {
                $link = New-Object KeeperSecurity.Compliance.ComplianceUserRecordLink
                $link.RecordUid = [string]$recUid
                $link.UserUid = [long]$ownerUid
                $userRecordLinks.Add($link)
            }
        }
        if ($userRecordLinks.Count -gt 0) {
            $storage.UserRecordLinks.PutLinks($userRecordLinks)
        }

        $teamEntities = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceTeam]]::new()
        $teamUserLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceTeamUserLink]]::new()
        foreach ($teamUid in $Snapshot.Teams.Keys) {
            $t = $Snapshot.Teams[$teamUid]
            $ct = New-Object KeeperSecurity.Compliance.ComplianceTeam
            $ct.TeamUid = [string]$teamUid
            $ct.TeamName = ''
            $ct.RestrictEdit = $false
            $ct.RestrictShare = $false
            $teamEntities.Add($ct)
            foreach ($memberUid in $t.Users) {
                $tl = New-Object KeeperSecurity.Compliance.ComplianceTeamUserLink
                $tl.TeamUid = [string]$teamUid
                $tl.UserUid = [long]$memberUid
                $teamUserLinks.Add($tl)
            }
        }
        if ($teamEntities.Count -gt 0) {
            $storage.Teams.PutEntities($teamEntities)
        }
        if ($teamUserLinks.Count -gt 0) {
            $storage.TeamUserLinks.PutLinks($teamUserLinks)
        }

        $sfRecordLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceSfRecordLink]]::new()
        $sfUserLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceSfUserLink]]::new()
        $sfTeamLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceSfTeamLink]]::new()
        $recPermLinks = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceRecordPermissions]]::new()
        foreach ($sfUid in $Snapshot.SharedFolders.Keys) {
            $sf = $Snapshot.SharedFolders[$sfUid]
            foreach ($recUid in $sf.RecordPermissions.Keys) {
                $srl = New-Object KeeperSecurity.Compliance.ComplianceSfRecordLink
                $srl.FolderUid = [string]$sfUid
                $srl.RecordUid = [string]$recUid
                $srl.Permissions = [int]$sf.RecordPermissions[$recUid]
                $sfRecordLinks.Add($srl)
            }
            foreach ($userUid in $sf.Users) {
                $sul = New-Object KeeperSecurity.Compliance.ComplianceSfUserLink
                $sul.FolderUid = [string]$sfUid
                $sul.UserUid = [long]$userUid
                $sfUserLinks.Add($sul)
            }
            foreach ($teamUid in $sf.Teams) {
                $stl = New-Object KeeperSecurity.Compliance.ComplianceSfTeamLink
                $stl.FolderUid = [string]$sfUid
                $stl.TeamUid = [string]$teamUid
                $sfTeamLinks.Add($stl)
            }
        }
        if ($sfRecordLinks.Count -gt 0) {
            $storage.SfRecordLinks.PutLinks($sfRecordLinks)
        }
        if ($sfUserLinks.Count -gt 0) {
            $storage.SfUserLinks.PutLinks($sfUserLinks)
        }
        if ($sfTeamLinks.Count -gt 0) {
            $storage.SfTeamLinks.PutLinks($sfTeamLinks)
        }

        foreach ($recUid in $Snapshot.Records.Keys) {
            $r = $Snapshot.Records[$recUid]
            if ($r.UserPermissions -and $r.UserPermissions.Count -gt 0) {
                foreach ($userUid in $r.UserPermissions.Keys) {
                    $rp = New-Object KeeperSecurity.Compliance.ComplianceRecordPermissions
                    $rp.RecordUid = [string]$recUid
                    $rp.UserUid = [long]$userUid
                    $rp.Permissions = [int]$r.UserPermissions[$userUid]
                    $recPermLinks.Add($rp)
                }
            }
        }
        if ($recPermLinks.Count -gt 0) {
            $storage.RecordPermissions.PutLinks($recPermLinks)
        }

        Write-KeeperComplianceStatus "Saved compliance snapshot to SQLite."
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to save SQLite cache: $($_.Exception.Message)"
    }
}

function Import-KeeperComplianceSnapshotFromSqlite {
    param(
        [Parameter(Mandatory = $true)][string]$CacheKey,
        [Parameter(Mandatory = $true)][TimeSpan]$CacheTtl,
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return $null }

        $meta = $storage.Metadata.Load()
        if (-not $meta) { return $null }
        if ($meta.AccountUid -ne $CacheKey) { return $null }

        $loadedEpoch = $meta.ComplianceDataLastUpdate
        if ($loadedEpoch -le 0) { return $null }
        $loadedAt = [DateTimeOffset]::FromUnixTimeSeconds($loadedEpoch).LocalDateTime
        if (((Get-Date) - $loadedAt) -ge $CacheTtl) { return $null }

        $snapshot = [PSCustomObject]@{
            Users              = @{}
            Records            = @{}
            SharedFolders      = @{}
            Teams              = @{}
            OwnedRecordsByUser = @{}
        }

        foreach ($cu in $storage.Users.GetAll()) {
            $email = if ($cu.Email) { [System.Text.Encoding]::UTF8.GetString($cu.Email) } else { '' }
            $fullName = if ($cu.FullName -and $cu.FullName.Length -gt 0) { [System.Text.Encoding]::UTF8.GetString($cu.FullName) } else { '' }
            $jobTitle = if ($cu.JobTitle -and $cu.JobTitle.Length -gt 0) { [System.Text.Encoding]::UTF8.GetString($cu.JobTitle) } else { '' }
            $snapshot.Users[[long]$cu.UserUid] = [PSCustomObject]@{
                UserUid  = [long]$cu.UserUid
                Email    = $email
                FullName = $fullName
                JobTitle = $jobTitle
                NodeId   = [long]$cu.NodeId
            }
        }

        foreach ($cr in $storage.Records.GetAll()) {
            $recData = @{ Title = ''; RecordType = ''; Url = '' }
            if ($cr.EncryptedData -and $cr.EncryptedData.Length -gt 0) {
                try {
                    $json = [System.Text.Encoding]::UTF8.GetString($cr.EncryptedData) | ConvertFrom-Json
                    $recData.Title = [string]$json.title
                    $recData.RecordType = [string]$json.type
                    $recData.Url = [string]$json.url
                } catch {
                    Write-Verbose -Message "[compliance] Could not parse record data for $($cr.RecordUid): $($_.Exception.Message)"
                }
            }
            $snapshot.Records[[string]$cr.RecordUid] = [PSCustomObject]@{
                Uid              = [string]$cr.RecordUid
                Title            = $recData.Title
                RecordType       = $recData.RecordType
                Url              = $recData.Url
                Shared           = [bool]$cr.Shared
                InTrash          = [bool]$cr.InTrash
                UserPermissions  = @{}
                SharedFolderUids = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
            }
        }

        foreach ($rp in $storage.RecordPermissions.GetAllLinks()) {
            $recUid = [string]$rp.RecordUid
            if ($snapshot.Records.ContainsKey($recUid)) {
                $snapshot.Records[$recUid].UserPermissions[[long]$rp.UserUid] = [int]$rp.Permissions
            }
        }

        foreach ($link in $storage.UserRecordLinks.GetAllLinks()) {
            $userUid = [long]$link.UserUid
            $recUid = [string]$link.RecordUid
            if (-not $snapshot.OwnedRecordsByUser.ContainsKey($userUid)) {
                $snapshot.OwnedRecordsByUser[$userUid] = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
            }
            $snapshot.OwnedRecordsByUser[$userUid].Add($recUid) | Out-Null
        }

        foreach ($ct in $storage.Teams.GetAll()) {
            $teamUid = [string]$ct.TeamUid
            $teamUsers = [System.Collections.Generic.HashSet[long]]::new()
            foreach ($tl in $storage.TeamUserLinks.GetLinksForSubject($teamUid)) {
                $teamUsers.Add([long]$tl.UserUid) | Out-Null
            }
            $snapshot.Teams[$teamUid] = [PSCustomObject]@{
                Uid   = $teamUid
                Users = $teamUsers
            }
        }

        $sfMap = @{}
        foreach ($srl in $storage.SfRecordLinks.GetAllLinks()) {
            $sfUid = [string]$srl.FolderUid
            if (-not $sfMap.ContainsKey($sfUid)) {
                $sfMap[$sfUid] = [PSCustomObject]@{
                    Uid               = $sfUid
                    Users             = [System.Collections.Generic.HashSet[long]]::new()
                    Teams             = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
                    RecordPermissions = @{}
                }
            }
            $sfMap[$sfUid].RecordPermissions[[string]$srl.RecordUid] = [int]$srl.Permissions
            if ($snapshot.Records.ContainsKey([string]$srl.RecordUid)) {
                $snapshot.Records[[string]$srl.RecordUid].SharedFolderUids.Add($sfUid) | Out-Null
            }
        }
        foreach ($sul in $storage.SfUserLinks.GetAllLinks()) {
            $sfUid = [string]$sul.FolderUid
            if (-not $sfMap.ContainsKey($sfUid)) {
                $sfMap[$sfUid] = [PSCustomObject]@{
                    Uid               = $sfUid
                    Users             = [System.Collections.Generic.HashSet[long]]::new()
                    Teams             = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
                    RecordPermissions = @{}
                }
            }
            $sfMap[$sfUid].Users.Add([long]$sul.UserUid) | Out-Null
        }
        foreach ($stl in $storage.SfTeamLinks.GetAllLinks()) {
            $sfUid = [string]$stl.FolderUid
            if (-not $sfMap.ContainsKey($sfUid)) {
                $sfMap[$sfUid] = [PSCustomObject]@{
                    Uid               = $sfUid
                    Users             = [System.Collections.Generic.HashSet[long]]::new()
                    Teams             = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
                    RecordPermissions = @{}
                }
            }
            $sfMap[$sfUid].Teams.Add([string]$stl.TeamUid) | Out-Null
        }
        $snapshot.SharedFolders = $sfMap

        return @{
            Snapshot        = $snapshot
            LoadedAt        = $loadedAt
            Incomplete      = $false
            SharedRecordsOnly = [bool]$meta.SharedRecordsOnly
        }
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to load SQLite cache: $($_.Exception.Message)"
        return $null
    }
}

function Import-KeeperComplianceAgingCacheFromSqlite {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return }

        if (-not $script:ComplianceAgingCache) {
            $script:ComplianceAgingCache = @{ Entries = @{} }
        }
        if (-not $script:ComplianceAgingCache.Entries) {
            $script:ComplianceAgingCache.Entries = @{}
        }
        $cacheTtl = [TimeSpan]::FromDays(1)
        $nowEpoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

        foreach ($aging in $storage.RecordAging.GetAll()) {
            $recUid = [string]$aging.RecordUid
            if ($script:ComplianceAgingCache.Entries.ContainsKey($recUid)) {
                continue
            }
            if ($aging.LastCached -le 0) { continue }
            if (($nowEpoch - $aging.LastCached) -ge $cacheTtl.TotalSeconds) { continue }

            $lcDt = [DateTimeOffset]::FromUnixTimeSeconds($aging.LastCached).LocalDateTime
            $script:ComplianceAgingCache.Entries[$recUid] = @{
                Created      = if ($aging.Created -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($aging.Created).LocalDateTime } else { $null }
                LastPwChange = if ($aging.LastPwChange -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($aging.LastPwChange).LocalDateTime } else { $null }
                LastModified = if ($aging.LastModified -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($aging.LastModified).LocalDateTime } else { $null }
                LastRotation = if ($aging.LastRotation -gt 0) { [DateTimeOffset]::FromUnixTimeSeconds($aging.LastRotation).LocalDateTime } else { $null }
                LastCached   = $lcDt
            }
        }
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to import aging from SQLite: $($_.Exception.Message)"
    }
}

function Save-KeeperComplianceAgingCacheToSqlite {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    if (-not $script:ComplianceAgingCache -or -not $script:ComplianceAgingCache.Entries) {
        return
    }
    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return }

        $entities = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceRecordAging]]::new()
        foreach ($k in $script:ComplianceAgingCache.Entries.Keys) {
            $e = $script:ComplianceAgingCache.Entries[$k]
            $ra = New-Object KeeperSecurity.Compliance.ComplianceRecordAging
            $ra.RecordUid = [string]$k
            $ra.Created = if ($e.Created) { [int64][DateTimeOffset]::new([datetime]$e.Created).ToUnixTimeSeconds() } else { 0L }
            $ra.LastPwChange = if ($e.LastPwChange) { [int64][DateTimeOffset]::new([datetime]$e.LastPwChange).ToUnixTimeSeconds() } else { 0L }
            $ra.LastModified = if ($e.LastModified) { [int64][DateTimeOffset]::new([datetime]$e.LastModified).ToUnixTimeSeconds() } else { 0L }
            $ra.LastRotation = if ($e.LastRotation) { [int64][DateTimeOffset]::new([datetime]$e.LastRotation).ToUnixTimeSeconds() } else { 0L }
            $ra.LastCached = if ($e.LastCached) { [int64][DateTimeOffset]::new([datetime]$e.LastCached).ToUnixTimeSeconds() } else { 0L }
            $entities.Add($ra)
        }
        if ($entities.Count -gt 0) {
            $storage.RecordAging.PutEntities($entities)
        }
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to save aging to SQLite: $($_.Exception.Message)"
    }
}

function Remove-KeeperComplianceSqliteCache {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    $script:ComplianceSqliteStorage = $null
    $script:ComplianceSqliteDbPath = $null
    $dbPath = Get-KeeperComplianceSqliteDbPath -Enterprise $Enterprise -Auth $Auth
    if (Test-Path -LiteralPath $dbPath) {
        try {
            [Microsoft.Data.Sqlite.SqliteConnection]::ClearAllPools()
            [System.GC]::Collect()
            [System.GC]::WaitForPendingFinalizers()
        }
        catch {
            Write-Verbose -Message "[compliance] Could not clear SQLite connection pools: $($_.Exception.Message)"
        }
        Remove-Item -LiteralPath $dbPath -Force -ErrorAction SilentlyContinue
        foreach ($sidecar in @("$dbPath-wal", "$dbPath-shm")) {
            if (Test-Path -LiteralPath $sidecar) {
                Remove-Item -LiteralPath $sidecar -Force -ErrorAction SilentlyContinue
            }
        }
        if (Test-Path -LiteralPath $dbPath) {
            Write-Warning "[compliance] SQLite cache file could not be deleted (may be locked): $dbPath"
        }
        else {
            Write-Verbose -Message "[compliance] Removed SQLite cache: $dbPath"
        }
    }
}

function Assert-KeeperComplianceReportAccess {
    $enterprise = getEnterprise
    if (-not $enterprise -or -not $enterprise.loader -or -not $enterprise.roleData) {
        Write-Error "Enterprise connection is required for compliance reports." -ErrorAction Stop
    }
    $auth = $enterprise.loader.Auth
    $username = [string]$auth.Username
    if ([string]::IsNullOrWhiteSpace($username)) {
        Write-Error "Could not determine login username for compliance access validation." -ErrorAction Stop
    }
    $enterpriseUser = $null
    if (-not $enterprise.enterpriseData.TryGetUserByEmail($username, [ref]$enterpriseUser) -or -not $enterpriseUser) {
        foreach ($u in $enterprise.enterpriseData.Users) {
            if ($u.Email -and [string]::Compare([string]$u.Email, $username, $true) -eq 0) {
                $enterpriseUser = $u
                break
            }
        }
    }
    if (-not $enterpriseUser) {
        Write-Error "Could not resolve your enterprise user for compliance access validation. Your login ($username) was not found among enterprise users." -ErrorAction Stop
    }
    $uid = [long]$enterpriseUser.Id
    $hasPrivilege = $false
    foreach ($roleId in @($enterprise.roleData.GetRolesForUser($uid))) {
        foreach ($rp in @($enterprise.roleData.GetRolePermissions($roleId))) {
            if ($rp.RunComplianceReports) {
                $hasPrivilege = $true
                break
            }
        }
        if ($hasPrivilege) {
            break
        }
    }
    if (-not $hasPrivilege) {
        Write-Error "You do not have the required privilege to run a Compliance Report (RUN_COMPLIANCE_REPORTS)." -ErrorAction Stop
    }
    $license = $enterprise.enterpriseData.EnterpriseLicense
    $addonOk = $false
    if ($license -and $license.AddOns) {
        foreach ($a in $license.AddOns) {
            if ([string]$a.Name -eq 'compliance_report' -and $a.Enabled) {
                $addonOk = $true
                break
            }
        }
    }
    if (-not $addonOk) {
        Write-Error "Compliance reports add-on is required to perform this action. Ask your administrator to enable the compliance_report add-on." -ErrorAction Stop
    }
}

function Import-KeeperComplianceAgingUserRefreshFromSqlite {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth
    )

    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return @{} }

        $h = @{}
        foreach ($cu in $storage.Users.GetAll()) {
            if ($cu.LastAgingRefreshed -gt 0) {
                $h[[string]$cu.UserUid] = [long]$cu.LastAgingRefreshed
            }
        }
        return $h
    }
    catch {
        return @{}
    }
}

function Save-KeeperComplianceAgingUserRefreshToSqlite {
    param(
        [Parameter(Mandatory = $true)]$Enterprise,
        [Parameter(Mandatory = $true)]$Auth,
        [Parameter(Mandatory = $true)][long[]]$UserIds
    )

    try {
        $storage = Get-KeeperComplianceSqliteStorage -Enterprise $Enterprise -Auth $Auth
        if (-not $storage) { return }

        $nowTs = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        $updates = [System.Collections.Generic.List[KeeperSecurity.Compliance.ComplianceUser]]::new()
        foreach ($id in $UserIds) {
            $existing = $storage.Users.GetEntity([string]$id)
            if ($existing) {
                $cu = New-Object KeeperSecurity.Compliance.ComplianceUser
                $cu.UserUid = $existing.UserUid
                $cu.Email = $existing.Email
                $cu.Status = $existing.Status
                $cu.JobTitle = $existing.JobTitle
                $cu.FullName = $existing.FullName
                $cu.NodeId = $existing.NodeId
                $cu.LastRefreshed = $existing.LastRefreshed
                $cu.LastComplianceRefreshed = $existing.LastComplianceRefreshed
                $cu.LastAgingRefreshed = $nowTs
                $updates.Add($cu)
            }
        }
        if ($updates.Count -gt 0) {
            $storage.Users.PutEntities($updates)
        }
    }
    catch {
        Write-Verbose -Message "[compliance] Failed to save aging user refresh to SQLite: $($_.Exception.Message)"
    }
}

function ConvertTo-KeeperComplianceRowPlainText {
    param([Parameter(Mandatory = $true)]$Row)

    $parts = [System.Collections.Generic.List[string]]::new()
    foreach ($p in $Row.PSObject.Properties) {
        if ($p.Name -eq 'permission_bits') {
            continue
        }
        $v = $p.Value
        if ($null -eq $v) {
            continue
        }
        if (($v -is [System.Array]) -or (($v -is [System.Collections.IEnumerable]) -and -not ($v -is [string]))) {
            $parts.Add(($v | ForEach-Object { [string]$_ }) -join ' ') | Out-Null
        }
        else {
            $parts.Add([string]$v) | Out-Null
        }
    }
    return (($parts | ForEach-Object { $_ }) -join ' ').ToLowerInvariant()
}

function Invoke-KeeperCompliancePatternFilterRows {
    param(
        [Parameter(Mandatory = $true)]$Rows,
        [Parameter()][string[]]$Patterns,
        [Parameter()][switch]$UseRegex,
        [Parameter()][switch]$MatchAll
    )

    if (-not $Patterns -or $Patterns.Count -eq 0) {
        return $Rows
    }

    function Test-PatternOne {
        param([string]$PatternStr, [string]$Plain)

        $s = $PatternStr.Trim()
        if ($s.StartsWith('regex:')) {
            $rx = $s.Substring(6)
            try {
                return [regex]::IsMatch($Plain, $rx, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
            }
            catch {
                return $Plain.Contains($rx.ToLowerInvariant())
            }
        }
        if ($s.StartsWith('exact:')) {
            $ex = $s.Substring(6)
            return $Plain -ceq $ex.ToLowerInvariant()
        }
        if ($s.StartsWith('not:')) {
            $rest = $s.Substring(4).Trim()
            if ($rest.StartsWith('regex:')) {
                $rx = $rest.Substring(6)
                try {
                    return -not [regex]::IsMatch($Plain, $rx, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
                }
                catch {
                    return $Plain -notlike "*$($rx.ToLowerInvariant())*"
                }
            }
            if ($rest.StartsWith('exact:')) {
                $ex = $rest.Substring(6)
                return $Plain -cne $ex.ToLowerInvariant()
            }
            return $Plain -notlike "*$($rest.ToLowerInvariant())*"
        }
        if ($UseRegex) {
            try {
                return [regex]::IsMatch($Plain, $s, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
            }
            catch {
                return $Plain.Contains($s.ToLowerInvariant())
            }
        }
        return $Plain.Contains($s.ToLowerInvariant())
    }

    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($row in $Rows) {
        $plain = ConvertTo-KeeperComplianceRowPlainText -Row $row
        $results = foreach ($p in $Patterns) {
            Test-PatternOne -PatternStr ([string]$p) -Plain $plain
        }
        $ok = if ($MatchAll) { @($results) -notcontains $false } else { @($results) -contains $true }
        if ($ok) {
            $out.Add($row) | Out-Null
        }
    }
    return @($out)
}


# SIG # Begin signature block
# MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC7HdnkVoESJXoi
# ouMUiPQziTKfOYoH6D83VFbl1IV3DKCCITswggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx
# oAMCAQICEAHdzU+FVN9jCMv0HhHagNUwDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNjA2MDUwMDAwMDBaFw0yNzA2MDQyMzU5NTlaMIHRMRMwEQYLKwYBBAGC
# NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ
# cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC
# VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK
# ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5
# IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCb4DRTV0sNQsa1
# 0YRh+bliabmLOVYr6S0+BSVvRJAN3SHP6x52i1Dkpki5xVDIH06ZnnsToVrgvTv+
# QxGwsn9SAPHEZ/PIJRFxbMR4ShDaptYyL4f0u4k/3HwRzIleWE4mTUonYH8BdgLw
# /F53B7wa7VTDHtxXltYTibEOwJxYCOi4Zr2FYQhjw14/CHcqS3FSMs6YYU2T56+g
# w819hQM3K0YlwTNOFoIm1v7/ZZZiJGH8uGDsvy1makh1Xyyo/wN8EbQ1nbslmePT
# roPm9w7WqiP/yiq+CZHiuTk9JK5bEgkWG3ns+v25cI251WidJx3SU7IZnX0OTd6/
# ZdKhprD5Gcfy5GBbJdcYw2WycQRW0PT5BEt55xRE0heufkpDaTUN6RdOuJdXbkl0
# hV91IZIuhueEMCk3h5mDTlU5gImxqj0R/TbAxjSSGTKCeuYFkQIRqytSabdrZZ48
# kW5hOIZMVDY1f4kpPJa8UeEvDZXT3vrtj36aSJrwez2uh4FMNlkCAwEAAaOCAgIw
# ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBT1
# SmCYU/7Yrz1fX66Ur5nSzlSYOzA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
# B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p
# bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT
# QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA
# A4ICAQBcavcUHNFEg872HDRq2+hRlnvaghCXv7X/6h9HSzjAQP3rt95BZty3ASqi
# 2MYyGQLGdDl4DToe/WhajtEOBOYa83agW6tBvrfcKRrDrwJOMPTbwNYvn+GuiL4T
# CKzXaytWiJJbrc5odc7Ecat2ZvJylpPmNainr4Q0LzzH23Gea/Mm/hIJTN4IGgrH
# hrXiTIIW/ZUzrY6g8b3RZB4BA497n43wNdSqP+C3ntFw6NiGB4Z25SW4YntIxYPv
# Kf37OVhF0xqxLC1sK/XxgK0EGQ6iaj8Ncpr2C5vSNZqfW2MndxOA1W67pgDpg83k
# UWG+/YJeGhqOTF82/0kIzQXeI/lIqbnL/IJAJqSm/ROSpsGUKVbzk03cpTD55ZQX
# WjM0fLirypBqY05T8gnh1L0fSwxr/SwJZ8OddivgyK1YOMn02nnsEG5kxBt9cMX4
# JCYABhypmAVDRvyYifEVdoFWv2gAXXW+PPRvlNa6E4aMCZrVcoKHiyeMAXOi1IC9
# mHvC2+foTSMFueq3AdnYfeKnZnAiKXKRhXcdHbQYcR2A7AIzIcqahPYr4FNEgb/E
# /y/kypAkf0rMHlYl1kNqLs2Nv1UnMEHYT5YmDVLO63+1Trcw4zTZ70zuqIqeID/d
# nbOlgtyG6DSRCL7f0E7kP18f4RoX5i1PkfeO4VJHsAuCeNG1qjGCBdkwggXVAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQAd3NT4VU32MIy/QeEdqA1TANBglghkgBZQMEAgEFAKCB
# hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE
# AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ
# BDEiBCCT/4lnbHQ7GIGin4b4h+X1cljwnPcXbf3ZmsCUD6RqmDANBgkqhkiG9w0B
# AQEFAASCAYBLrdGi3bbcwvbToTNqEkwpVNTd1FSxDeJpkeK1wXQE3uBDTzLGG9YO
# zWxlok2j91yiDZ6losoyuUM15e6S04XqkEix8bV0qkR8p46sWjLIBan/sQgtaaaW
# HrsBILRCmHXVo/u20A+87lrpkvoiMh3I/aVX02xCDopwwFVo31TE8XFjfSzgT4sY
# udGbVut6suHrpV03jl203M07GGJ6DFaojIhuJ35T2gPwUCOpj44zPmbBGFXvanws
# iHo9YZ7qXAou/7L0ZM6Fszd3TkVmYKvphkX6dCkADgyOaWnFgpe2ffNwpSz13pZJ
# mmiaATU5gYhAeqa3ZbslVVLPrdiLbYip2jyUQf5nQ5tplh58P2DtmqvgkueyNVlC
# hg2VzIGKB/J4yfcgwnnc6xfjIDEJQP9hngDaa1fiI9vOGtZDfGPhIj54f2aoKe/l
# 2QOVUXkHC6TDN9G4xBCMs2j5OXzybcqCwAFnenPRZ1IdSfgdZa597YHZ8zE3nXx+
# TpbNtM4KNRGhggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD
# VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD
# ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg
# Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN
# AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwNjEzMDA1NjM2WjAv
# BgkqhkiG9w0BCQQxIgQgdYbkvspMRK9mTUbnhN9wfxNzw7SOQDrpkFuvk6QG+NQw
# DQYJKoZIhvcNAQEBBQAEggIAMB2w6WG2rWTmxlIWyxk3l8uby6vhsZJzHhIKgyey
# LeZuhOLYbk7e4mx4J1Gp0k7jx9ak3gyw2/hXjQp6GuSB/tNf3yTN2BK7YGU3bXzc
# K4XSFV1ZIpfnGtKWqsenV/rtubv6Ebs93X4dQaWDz6O3k1Hs4yT60Nhag+ixwaoA
# sE/fQnnaybG1lW3tau9wDbEECkw9IFI0cMZCjVh993ObIxMdjvzbTjie6qr1Sklm
# GDcK6POG+2J8T3wukXO6GjswvvOAWsvuSNccvZXtuUFRHxB2ksZIWTsW1e4l8cT/
# Pk0UIEYZqCZWs10QISrmMI9RLzkCDsgDyQXIPd+HmN9TRgvbf5O145xQ77EGYtno
# Ub1rlJ6PzGCMg8362gGxOY5+big4T/NNhfTvtuDktJAmZzl1uiXqrSIBMrfcT2jv
# ztTckOVBD/qfplvBrYHUXZvh3/+ncs89KcLFI7wDsWKtNGPxuRrs1nu7XMkZ+Wth
# Xc9eZsYbaMx0x+ONvMUsiythe7qVWDRkih++fvWvT4ea1zIskaPbNtd4iaqWtHYY
# VdM28R6HMSVzsepcplUnisVvwiiECSIuUaa6RCsLi/HAFC/XS9Y5hhCGAILfhh3P
# ZJdt49QmX5mFjLNs1oQ3feYS8wssQHj1TRo8c00OSndiuYEEuZRhgBr1yk8zzNzm
# Tz0=
# SIG # End signature block