Src/Private/Helpers.ps1
|
function Test-UserPrincipalName { <# .SYNOPSIS Validates that a string is a properly formatted User Principal Name. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [string]$UserPrincipalName ) return ($UserPrincipalName -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') } function Write-TranscriptLog { <# .SYNOPSIS Writes a structured log entry to the host and optionally to a transcript file. .DESCRIPTION Provides consistent log output across the module. Level values: DEBUG | INFO | SUCCESS | WARNING | ERROR Category is a short tag e.g. AUTH, COMPLIANCE, DEVICES etc. #> [CmdletBinding()] param ( [Parameter(Mandatory, Position = 0)] [string]$Message, [Parameter(Position = 1)] [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')] [string]$Level = 'INFO', [Parameter(Position = 2)] [string]$Category = 'GENERAL' ) $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $Entry = "[$Timestamp] [$Level] [$Category] $Message" $null = switch ($Level) { 'DEBUG' { Write-PScriboMessage -Message $Entry } 'INFO' { Write-Host " $Entry" } 'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green } 'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry } 'ERROR' { Write-Host " $Entry" -ForegroundColor Red } } if ($script:TranscriptLogPath) { try { Add-Content -Path $script:TranscriptLogPath -Value $Entry -ErrorAction SilentlyContinue | Out-Null } catch { } } } function Invoke-WithRetry { <# .SYNOPSIS Executes a ScriptBlock with configurable retry logic and exponential back-off. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [scriptblock]$ScriptBlock, [Parameter(Mandatory)] [string]$OperationName, [int]$MaxAttempts = 3, [int]$DelaySeconds = 5 ) $Attempt = 0 $CurrentDelay = $DelaySeconds while ($Attempt -lt $MaxAttempts) { $Attempt++ try { & $ScriptBlock return } catch { if ($Attempt -eq $MaxAttempts) { Write-Warning "[$OperationName] Failed after $MaxAttempts attempts: $($_.Exception.Message)" throw } Write-Host " [RETRY] $OperationName attempt $Attempt/$MaxAttempts failed. Retrying in ${CurrentDelay}s..." -ForegroundColor Yellow Start-Sleep -Seconds $CurrentDelay $CurrentDelay = [math]::Min($CurrentDelay * 2, 60) } } } function ConvertTo-HashToYN { <# .SYNOPSIS Converts boolean values in a hashtable to Yes/No strings for report display. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [hashtable]$InputHash ) $Result = [ordered]@{} foreach ($Key in $InputHash.Keys) { $Val = $InputHash[$Key] if ($Val -is [bool]) { $Result[$Key] = if ($Val) { 'Yes' } else { 'No' } } elseif ($null -eq $Val) { $Result[$Key] = '--' } else { $Result[$Key] = $Val } } return $Result } function Show-AbrDebugExecutionTime { <# .SYNOPSIS Records and optionally displays section execution timing. #> [CmdletBinding()] param ( [switch]$Start, [switch]$End, [string]$TitleMessage ) if ($Start) { $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew() Write-AbrDebugLog "Section [$TitleMessage] started" 'DEBUG' 'TIMING' } if ($End -and $script:SectionTimers.ContainsKey($TitleMessage)) { $script:SectionTimers[$TitleMessage].Stop() $elapsed = $script:SectionTimers[$TitleMessage].Elapsed.TotalSeconds Write-AbrDebugLog "Section [$TitleMessage] completed in $([math]::Round($elapsed,2))s" 'INFO' 'TIMING' } } function Write-AbrSectionError { <# .SYNOPSIS Handles section errors — logs to console/transcript only; never writes to the document. 403 Forbidden responses (licensing or permissions issues) are silently skipped in the report output. Genuine unexpected errors are also logged but not rendered in the document to avoid polluting output with stack-trace noise. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Section, [Parameter(Mandatory)] [string]$Message ) if ($Message -like '*Forbidden*' -or $Message -like '*403*') { if ($script:GraphScopesFullyConfirmed -eq $true) { # Scopes OK + 403 = feature not licensed or not provisioned on this tenant — skip silently Write-Host " [SKIP] '$Section' — 403 with confirmed scopes (feature likely not licensed on this tenant). Section omitted." -ForegroundColor DarkYellow Write-TranscriptLog "'$Section' not available on this tenant (403 with confirmed scopes -- likely licensing)" 'WARNING' $Section } else { # Missing scopes + 403 = permissions issue — skip silently Write-Host " [SKIP] '$Section' — 403 Forbidden (insufficient permissions). Requires 'Intune Service Administrator' or 'Global Administrator'. Section omitted." -ForegroundColor DarkYellow Write-TranscriptLog "Insufficient permissions for '$Section': $Message" 'WARNING' $Section } } else { # Unexpected error — log only, do not render in document Write-Host " [ERROR] '$Section': $Message" -ForegroundColor Red Write-TranscriptLog "Error in section '$Section': $Message" 'ERROR' $Section } } function Export-IntuneToExcel { <# .SYNOPSIS Exports collected Intune data sheets to an Excel workbook using ImportExcel. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary]$Sheets, [Parameter(Mandatory)] [string]$Path, [string]$TenantId = '' ) $ImportExcelAvailable = Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue if (-not $ImportExcelAvailable) { Write-Host ' - ImportExcel module not found. Skipping Excel export.' -ForegroundColor Yellow Write-TranscriptLog 'ImportExcel module not found. Excel export skipped.' 'WARNING' 'EXPORT' return } Import-Module ImportExcel -ErrorAction SilentlyContinue Write-Host " - Writing $($Sheets.Count) sheet(s) to: $Path" -ForegroundColor Cyan foreach ($SheetName in $Sheets.Keys) { $Data = $Sheets[$SheetName] if (-not $Data -or @($Data).Count -eq 0) { continue } try { $ExcelParams = @{ Path = $Path WorksheetName = $SheetName AutoSize = $true AutoFilter = $true BoldTopRow = $true FreezeTopRow = $true PassThru = $false } if (Test-Path $Path) { $ExcelParams['Append'] = $true } $Data | Export-Excel @ExcelParams Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)" -ForegroundColor DarkGray } catch { Write-Warning " - Failed to write sheet '$SheetName': $($_.Exception.Message)" } } Write-Host " - Excel workbook saved: $Path" -ForegroundColor Green Write-TranscriptLog "Excel workbook saved: $Path" 'SUCCESS' 'EXPORT' } function Write-AbrPermissionError { <# .SYNOPSIS Handles 403-based section skips — logs to console/transcript only; never writes to the document. When Graph scopes are fully confirmed, interprets 403 as a licensing/feature availability issue rather than an RBAC permissions problem. Either way the section is omitted silently from the report output so the document is not cluttered with skip notices. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Section, [Parameter(Mandatory)] [string]$RequiredRole, [string]$Message = '' ) if ($script:GraphScopesFullyConfirmed -eq $true) { # All scopes present + 403 = feature not licensed/provisioned on this tenant — skip silently Write-Host " [SKIP] '$Section' — 403 with all scopes confirmed (feature likely not licensed on this tenant). Section omitted." -ForegroundColor DarkYellow Write-TranscriptLog "'$Section' not available on this tenant -- likely a licensing or feature provisioning issue" 'WARNING' $Section } else { # Missing role + 403 = permissions issue — skip silently $detail = if ($Message) { " ($Message)" } else { '' } Write-Host " [SKIP] '$Section'$detail — requires '$RequiredRole' in Entra ID. Section omitted." -ForegroundColor DarkYellow Write-TranscriptLog "Insufficient permissions for '$Section' -- requires: $RequiredRole" 'WARNING' $Section } } function Test-AbrGraphForbidden { <# .SYNOPSIS Returns $true if an exception is a 403 Forbidden Graph API response. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) return ($ErrorRecord.Exception.Message -like '*Forbidden*' -or $ErrorRecord.Exception.Message -like '*403*') } function Get-IntuneExcelSheetEnabled { <# .SYNOPSIS Returns $true if a given Excel sheet is enabled in Options.ExcelExport.Sheets. Returns $false if ExcelExport.Enabled is globally false or the sheet key is set to false. Defaults to $true if the key is absent (backwards compatible with old config files). #> [CmdletBinding()] [OutputType([bool])] param([string]$SheetKey) if (-not $script:ExcelEnabled) { return $false } $sheetsConfig = $script:Options.ExcelExport.Sheets if ($null -eq $sheetsConfig) { return $true } # entire Sheets block absent = all enabled $val = $sheetsConfig.$SheetKey if ($null -eq $val) { return $true } # specific key absent = enabled return ($val -eq $true) } function Get-IntuneBackupSectionEnabled { <# .SYNOPSIS Returns $true if a given backup section is enabled in Options.JsonBackup.IncludeSections. Returns $false if JsonBackup.Enabled is globally false or the section key is set to false. Defaults to $true if the key is absent (backwards compatible with old config files). #> [CmdletBinding()] [OutputType([bool])] param([string]$SectionKey) if (-not $script:JsonBackupEnabled) { return $false } $sectionsConfig = $script:Options.JsonBackup.IncludeSections if ($null -eq $sectionsConfig) { return $true } $val = $sectionsConfig.$SectionKey if ($null -eq $val) { return $true } # key absent = enabled return ($val -eq $true) } |