Private/GraphHelpers.ps1
|
function Get-InTUIGraphConnectionDisplay { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Context, [Parameter()] [string]$ClientId, [Parameter()] [switch]$ClientCredential ) $connectionType = if ($ClientCredential -or [string]$Context.AuthType -eq 'AppOnly') { 'Service Principal' } else { 'User' } $account = if ($connectionType -eq 'Service Principal') { $Context.AppName ?? $ClientId ?? 'Service Principal' } else { $Context.Account ?? 'Unknown' } return [pscustomobject]@{ Account = $account ConnectionType = $connectionType } } function Show-InTUIGraphScopes { [CmdletBinding()] param() if (-not $script:Connected) { Show-InTUIWarning "Not connected to Microsoft Graph." Read-InTUIKey return } $context = Get-MgContext -ErrorAction SilentlyContinue if (-not $context) { Show-InTUIWarning "No active Microsoft Graph context found." Read-InTUIKey return } $connectionDisplay = Get-InTUIGraphConnectionDisplay -Context $context $scopes = @($context.Scopes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object -Unique) if ($scopes.Count -eq 0) { Show-InTUIWarning "The current Microsoft Graph context does not report any scopes." Read-InTUIKey return } $content = @( "[grey]Tenant:[/] $($context.TenantId ?? 'Unknown')" "[grey]Account:[/] $($connectionDisplay.Account)" "[grey]Auth:[/] $($connectionDisplay.ConnectionType)" '' "[grey]Scopes:[/]" ) foreach ($scope in $scopes) { $content += "- $scope" } Show-InTUIPanel -Title "[blue]Current Graph Scopes[/]" -Content ($content -join "`n") -BorderColor Blue Read-InTUIKey } function Connect-InTUIGraph { <# .SYNOPSIS Connects to Microsoft Graph with required scopes for Intune management. .PARAMETER Scopes Graph API permission scopes to request (interactive auth only). .PARAMETER TenantId Optional tenant ID or domain. .PARAMETER ClientId Application (client) ID for service principal auth. .PARAMETER ClientSecret Client secret for service principal auth. .PARAMETER Environment Cloud environment: Global, USGov, USGovDoD, or China. #> [CmdletBinding()] param( [Parameter()] [string[]]$Scopes = @( 'DeviceManagementManagedDevices.ReadWrite.All', 'DeviceManagementManagedDevices.PrivilegedOperations.All', 'DeviceManagementApps.ReadWrite.All', 'User.Read.All', 'Group.Read.All', 'GroupMember.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementServiceConfig.Read.All', 'Directory.Read.All', 'AuditLog.Read.All', 'BitlockerKey.ReadBasic.All', 'BitlockerKey.Read.All' ), [Parameter()] [string]$TenantId, [Parameter()] [string]$ClientId, [Parameter()] [string]$ClientSecret, [Parameter()] [ValidateSet('Global', 'USGov', 'USGovDoD', 'China')] [string]$Environment = 'Global', [Parameter()] [switch]$UseDeviceCode ) $script:UseDeviceCode = $UseDeviceCode.IsPresent $script:CloudEnvironment = $Environment $envConfig = $script:CloudEnvironments[$Environment] $script:GraphBaseUrl = $envConfig.GraphBaseUrl $script:GraphBetaUrl = $envConfig.GraphBetaUrl $useClientCredential = $ClientId -and $ClientSecret -and $TenantId $authMode = if ($useClientCredential) { 'ClientCredential' } elseif ($UseDeviceCode) { 'DeviceCode' } else { 'Interactive' } Write-InTUILog -Message "Connecting to Microsoft Graph" -Context @{ Environment = $Environment GraphBaseUrl = $script:GraphBaseUrl TenantId = $TenantId AuthMode = $authMode } try { if ($useClientCredential) { $secureSecret = ConvertTo-SecureString -String $ClientSecret -AsPlainText -Force $credential = [System.Management.Automation.PSCredential]::new($ClientId, $secureSecret) Connect-MgGraph -TenantId $TenantId -ClientSecretCredential $credential -NoWelcome:$true -Environment $envConfig.MgEnvironment } else { $params = @{ Scopes = $Scopes ContextScope = 'Process' NoWelcome = $true Environment = $envConfig.MgEnvironment } if ($TenantId) { $params['TenantId'] = $TenantId } if ($UseDeviceCode) { $params['UseDeviceCode'] = $true } Connect-MgGraph @params } $context = Get-MgContext if (-not $context) { return $false } $script:Connected = $true $script:TenantId = $context.TenantId $connectionDisplay = Get-InTUIGraphConnectionDisplay -Context $context -ClientId $ClientId -ClientCredential:$useClientCredential $script:Account = $connectionDisplay.Account $script:ConnectionType = $connectionDisplay.ConnectionType Write-InTUILog -Message "Connected to Microsoft Graph" -Context @{ TenantId = $context.TenantId Account = $script:Account ConnectionType = $script:ConnectionType Environment = $Environment } return $true } catch { Write-InTUILog -Level 'ERROR' -Message "Failed to connect to Microsoft Graph: $($_.Exception.Message)" Write-InTUIText "[red]Failed to connect to Microsoft Graph: $($_.Exception.Message)[/]" return $false } } function Reconnect-InTUIGraph { [CmdletBinding()] param( [Parameter()] [string[]]$Scopes, [Parameter()] [string]$TenantId, [Parameter()] [ValidateSet('Global', 'USGov', 'USGovDoD', 'China')] [string]$Environment, [Parameter()] [switch]$UseDeviceCode ) $context = Get-MgContext -ErrorAction SilentlyContinue $currentScopes = if ($context) { @($context.Scopes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object -Unique) } else { @() } if (-not $PSBoundParameters.ContainsKey('TenantId') -and -not [string]::IsNullOrWhiteSpace([string]$script:TenantId)) { $TenantId = $script:TenantId } if (-not $PSBoundParameters.ContainsKey('Environment')) { $Environment = if ([string]::IsNullOrWhiteSpace([string]$script:CloudEnvironment)) { 'Global' } else { $script:CloudEnvironment } } if (-not $PSBoundParameters.ContainsKey('Scopes')) { $Scopes = $currentScopes } $scopeList = @($Scopes) if (-not $PSBoundParameters.ContainsKey('UseDeviceCode') -and $script:UseDeviceCode) { $UseDeviceCode = [switch]$true } Write-InTUILog -Message 'Reconnecting to Microsoft Graph' -Context @{ TenantId = $TenantId Environment = $Environment ScopeCount = $scopeList.Count UseDeviceCode = $UseDeviceCode.IsPresent } Disconnect-MgGraph -ErrorAction SilentlyContinue $script:Connected = $false $connectParams = @{ Environment = $Environment } if (-not [string]::IsNullOrWhiteSpace($TenantId)) { $connectParams['TenantId'] = $TenantId } if ($scopeList.Count -gt 0) { $connectParams['Scopes'] = $scopeList } if ($UseDeviceCode) { $connectParams['UseDeviceCode'] = $true } return (Connect-InTUIGraph @connectParams) } function Get-InTUIGraphErrorRawDetails { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $rawDetails = $ErrorRecord.ErrorDetails.Message if (-not [string]::IsNullOrWhiteSpace($rawDetails)) { return $rawDetails } $responseContent = $ErrorRecord.Exception.Response?.Content if ($null -eq $responseContent) { return $null } try { return $responseContent.ReadAsStringAsync().GetAwaiter().GetResult() } catch { return $null } } function Get-InTUIIntuneNestedErrorMessage { [CmdletBinding()] param( [Parameter()] [string]$Text ) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $nestedCodeMatch = [regex]::Match($Text, '(?:\\"|")ErrorCode(?:\\"|")\s*:\s*(?:\\"|")(?<code>[^"\\]+)') $activityIdMatch = [regex]::Match($Text, 'Activity ID:\s*(?<activityId>[0-9a-fA-F-]{36})') if (-not $nestedCodeMatch.Success -or -not $activityIdMatch.Success) { return $null } return "$($nestedCodeMatch.Groups['code'].Value): Intune service rejected the request. Activity ID: $($activityIdMatch.Groups['activityId'].Value)" } function ConvertFrom-InTUIGraphErrorJson { [CmdletBinding()] param( [Parameter()] [string]$RawDetails ) if ([string]::IsNullOrWhiteSpace($RawDetails)) { return $null } try { $errorDetail = $RawDetails | ConvertFrom-Json } catch { return $null } $intuneMessage = Get-InTUIIntuneNestedErrorMessage -Text $RawDetails if ($intuneMessage) { return $intuneMessage } if ($errorDetail.error) { $code = [string]$errorDetail.error.code $message = [string]$errorDetail.error.message if ([string]::IsNullOrWhiteSpace($code)) { return $message } return "$code`: $message" } if ($errorDetail.message) { return [string]$errorDetail.message } return $RawDetails } function Get-InTUIGraphErrorMessage { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $errorMessage = $ErrorRecord.Exception.Message $rawDetails = Get-InTUIGraphErrorRawDetails -ErrorRecord $ErrorRecord $errorText = $rawDetails if ([string]::IsNullOrWhiteSpace($errorText) -and $errorMessage -match '\{"error"') { $jsonStart = $errorMessage.IndexOf('{"error"') if ($jsonStart -ge 0) { $rawDetails = $errorMessage.Substring($jsonStart) $errorText = $rawDetails } } $parsedErrorMessage = (ConvertFrom-InTUIGraphErrorJson -RawDetails $errorText) ?? (Get-InTUIIntuneNestedErrorMessage -Text $errorText) ?? (Get-InTUIIntuneNestedErrorMessage -Text $errorMessage) if ($parsedErrorMessage) { $errorMessage = $parsedErrorMessage } if ([string]::IsNullOrWhiteSpace($errorMessage)) { $errorMessage = "Request failed (HTTP $($ErrorRecord.Exception.Response.StatusCode))" if ($ErrorRecord.Exception.Response.ReasonPhrase) { $errorMessage += " - $($ErrorRecord.Exception.Response.ReasonPhrase)" } } return [pscustomobject]@{ Message = $errorMessage RawBody = $rawDetails } } function Invoke-InTUIGraphRequest { <# .SYNOPSIS Wrapper around Invoke-MgGraphRequest with error handling and pagination support. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [Parameter()] [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')] [string]$Method = 'GET', [Parameter()] [hashtable]$Body, [Parameter()] [switch]$Beta, [Parameter()] [switch]$All, [Parameter()] [int]$Top = 0, [Parameter()] [hashtable]$Headers, [Parameter()] [ValidateRange(1, 10000)] [int]$MaxPages = 500, [Parameter()] [switch]$NoCache, [Parameter()] [switch]$SuppressErrorOutput ) if (-not $script:Connected) { Write-InTUILog -Level 'WARN' -Message "Graph request attempted while not connected" -Context @{ Uri = $Uri } Write-InTUIText "[red]Not connected to Microsoft Graph. Run Connect-InTUI first.[/]" return $null } $baseUrl = if ($Beta) { $script:GraphBetaUrl } else { $script:GraphBaseUrl } if ($Uri -notmatch '^https://') { $fullUri = "$baseUrl/$($Uri.TrimStart('/'))" } else { $fullUri = $Uri } if ($Top -gt 0 -and $Method -eq 'GET') { $separator = if ($fullUri -match '\?') { '&' } else { '?' } $fullUri = "$fullUri$separator`$top=$Top" } # Check cache for GET requests if ($Method -eq 'GET' -and $script:CacheEnabled -and -not $NoCache) { $cached = Get-InTUICachedResponse -Uri $fullUri -Method $Method -Beta:$Beta if ($null -ne $cached) { return $cached } } Write-InTUILog -Message "Graph API request" -Context @{ Method = $Method Uri = $fullUri Beta = [bool]$Beta All = [bool]$All Environment = $script:CloudEnvironment } $params = @{ Uri = $fullUri Method = $Method OutputType = 'PSObject' } if ($Headers) { $params['Headers'] = $Headers } if ($Body) { $params['Body'] = $Body | ConvertTo-Json -Depth 10 $params['ContentType'] = 'application/json' } try { $script:LastGraphError = $null $response = Invoke-MgGraphRequest @params if ($All -and $Method -eq 'GET') { $allResults = [System.Collections.Generic.List[object]]::new() if ($response.value) { $allResults.AddRange(@($response.value)) } $pageCount = 1 while ($response.'@odata.nextLink') { $pageCount++ if ($pageCount -gt $MaxPages) { throw "Graph pagination exceeded max page limit of $MaxPages for '$fullUri'." } Write-InTUILog -Message "Fetching pagination page $pageCount" -Context @{ NextLink = $response.'@odata.nextLink' } $response = Invoke-MgGraphRequest -Uri $response.'@odata.nextLink' -Method GET -OutputType PSObject if ($response.value) { $allResults.AddRange(@($response.value)) } } Write-InTUILog -Message "Graph API request completed" -Context @{ TotalResults = $allResults.Count; Pages = $pageCount } # Cache the paginated results if ($script:CacheEnabled -and -not $NoCache) { Set-InTUICachedResponse -Uri $fullUri -Data $allResults -Method $Method -Beta:$Beta } return $allResults } $resultCount = if ($response.value) { @($response.value).Count } else { 1 } Write-InTUILog -Message "Graph API request completed" -Context @{ ResultCount = $resultCount } # Cache single-page response if ($Method -eq 'GET' -and $script:CacheEnabled -and -not $NoCache) { Set-InTUICachedResponse -Uri $fullUri -Data $response -Method $Method -Beta:$Beta } # Return $true for no-content success (e.g., 204) so $null exclusively means error return ($response ?? $true) } catch { $graphError = Get-InTUIGraphErrorMessage -ErrorRecord $_ $errorMessage = $graphError.Message Write-InTUILog -Level 'ERROR' -Message "Graph API Error: $errorMessage" -Context @{ Uri = $fullUri; Method = $Method } $script:LastGraphError = [pscustomobject]@{ Message = $errorMessage Uri = $fullUri Method = $Method StatusCode = $_.Exception.Response.StatusCode RawBody = $graphError.RawBody } if (-not $SuppressErrorOutput) { Write-InTUIText "[red]Graph API Error: $errorMessage[/]" } return $null } } function Get-InTUIPagedResults { <# .SYNOPSIS Gets paged results from Graph API with navigation support. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [Parameter()] [switch]$Beta, [Parameter()] [int]$PageSize = $script:PageSize, [Parameter()] [string]$Filter, [Parameter()] [string]$Search, [Parameter()] [string]$Select, [Parameter()] [string]$OrderBy, [Parameter()] [string]$Expand, [Parameter()] [hashtable]$Headers, [Parameter()] [switch]$IncludeCount ) $queryParams = @() if ($PageSize -gt 0) { $queryParams += "`$top=$PageSize" } if ($Filter) { $queryParams += "`$filter=$Filter" } if ($Search) { $searchValue = if ($Search.StartsWith('"')) { $Search } else { "`"$Search`"" } $queryParams += "`$search=$searchValue" } if ($Select) { $queryParams += "`$select=$Select" } if ($OrderBy) { $queryParams += "`$orderby=$OrderBy" } if ($Expand) { $queryParams += "`$expand=$Expand" } if ($IncludeCount) { $queryParams += "`$count=true" } $fullUri = $Uri if ($queryParams.Count -gt 0) { $fullUri = "$Uri`?$($queryParams -join '&')" } $params = @{ Uri = $fullUri } if ($Beta) { $params['Beta'] = $true } if ($Headers) { $params['Headers'] = $Headers } $response = Invoke-InTUIGraphRequest @params # Guard: null or non-object response (e.g. $true from 204 No Content) if ($null -eq $response -or $response -is [bool]) { return @{ Results = @(); NextLink = $null; TotalCount = 0 } } $results = if ($response.value) { @($response.value) } elseif ($response -is [array]) { $response } else { @() } $resultCount = @($results).Count $odataCount = $response.'@odata.count' # Use @odata.count only when present and sensible (>= page results); otherwise use actual count $totalCount = if ($null -ne $odataCount -and $odataCount -ge $resultCount) { $odataCount } else { $resultCount } return @{ Results = $results NextLink = $response.'@odata.nextLink' TotalCount = $totalCount } } function ConvertTo-InTUISafeFilterValue { <# .SYNOPSIS Escapes a string for safe use inside an OData $filter expression. #> param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return $Value } return $Value -replace "'", "''" } function Format-InTUIDate { <# .SYNOPSIS Formats a date string for display. #> param([string]$DateString) if ([string]::IsNullOrEmpty($DateString)) { return 'N/A' } try { $date = [DateTime]::Parse($DateString) $now = [DateTime]::UtcNow $diff = $now - $date if ($diff.TotalMinutes -lt 60) { return "$([math]::Floor($diff.TotalMinutes))m ago" } elseif ($diff.TotalHours -lt 24) { return "$([math]::Floor($diff.TotalHours))h ago" } elseif ($diff.TotalDays -lt 7) { return "$([math]::Floor($diff.TotalDays))d ago" } else { return $date.ToString('yyyy-MM-dd HH:mm') } } catch { return $DateString } } function Get-InTUIComplianceColor { <# .SYNOPSIS Returns markup color name based on compliance state. #> param([string]$State) switch ($State) { 'compliant' { return 'green' } 'noncompliant' { return 'red' } 'error' { return 'red' } 'inGracePeriod' { return 'yellow' } 'configManager' { return 'blue' } 'conflict' { return 'orange1' } default { return 'grey' } } } function Get-InTUIInstallStateColor { <# .SYNOPSIS Returns markup color name based on app install state. #> param([string]$State) switch ($State) { 'installed' { return 'green' } 'failed' { return 'red' } 'uninstallFailed' { return 'red' } 'notInstalled' { return 'grey' } 'notApplicable' { return 'grey' } default { return 'yellow' } } } function Get-InTUIDeviceIcon { <# .SYNOPSIS Returns an icon character based on OS type. #> param([string]$OperatingSystem) switch -Wildcard ($OperatingSystem) { '*Windows*' { return '[blue]W[/]' } '*iOS*' { return '[grey]i[/]' } '*iPadOS*' { return '[grey]P[/]' } '*macOS*' { return '[grey]m[/]' } '*Android*' { return '[green]A[/]' } '*Linux*' { return '[yellow]L[/]' } default { return '[grey]-[/]' } } } function Get-InTUIAppTypeIcon { <# .SYNOPSIS Returns an icon based on application type. #> param([string]$AppType) switch -Wildcard ($AppType) { '*win32*' { return '[blue]W[/]' } '*msi*' { return '[blue]M[/]' } '*ios*' { return '[grey]i[/]' } '*android*' { return '[green]A[/]' } '*webApp*' { return '[cyan]w[/]' } '*office*' { return '[orange1]O[/]' } '*microsoft*' { return '[blue]M[/]' } '*store*' { return '[cyan]S[/]' } '*managed*' { return '[yellow]m[/]' } default { return '[grey]-[/]' } } } function Get-InTUIPolicyIcon { <# .SYNOPSIS Returns an icon based on policy type. #> param([string]$PolicyType) switch -Wildcard ($PolicyType) { '*compliance*' { return '[green]+[/]' } '*configuration*' { return '[blue]*[/]' } '*conditional*' { return '[yellow]![/]' } '*security*' { return '[red]#[/]' } '*update*' { return '[cyan]~[/]' } default { return '[grey]-[/]' } } } function Get-InTUISecurityIcon { <# .SYNOPSIS Returns security-related icons. #> param( [Parameter(Mandatory)] [ValidateSet('Shield', 'Lock', 'Unlock', 'Key', 'Warning', 'Error', 'Check', 'Cross')] [string]$Type ) switch ($Type) { 'Shield' { return '[blue]#[/]' } 'Lock' { return '[green]#[/]' } 'Unlock' { return '[yellow]-[/]' } 'Key' { return '[yellow]k[/]' } 'Warning' { return '[yellow]![/]' } 'Error' { return '[red]x[/]' } 'Check' { return '[green]+[/]' } 'Cross' { return '[red]x[/]' } } } |