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 Assert-InTUIGraphDelegatedAuthMode { [CmdletBinding()] param( [Parameter()] [switch]$UseDeviceCode, [Parameter()] [switch]$UseBrowserAuth ) if ($UseDeviceCode -and $UseBrowserAuth) { throw 'UseDeviceCode and UseBrowserAuth are mutually exclusive delegated authentication modes.' } } function Assert-InTUIGraphAuthMode { [CmdletBinding()] param( [Parameter()] [switch]$ClientCredential, [Parameter()] [switch]$UseDeviceCode, [Parameter()] [switch]$UseBrowserAuth ) Assert-InTUIGraphDelegatedAuthMode -UseDeviceCode:$UseDeviceCode -UseBrowserAuth:$UseBrowserAuth if ($ClientCredential -and ($UseDeviceCode -or $UseBrowserAuth)) { throw 'Client credential authentication cannot be combined with delegated authentication switches.' } } function Resolve-InTUIGraphDelegatedAuthMode { [CmdletBinding()] param( [Parameter()] [switch]$UseDeviceCode, [Parameter()] [switch]$UseBrowserAuth ) Assert-InTUIGraphAuthMode -UseDeviceCode:$UseDeviceCode -UseBrowserAuth:$UseBrowserAuth if ($UseDeviceCode) { return 'DeviceCode' } return 'BrowserAuth' } function Connect-InTUIGraph { <# .SYNOPSIS Connects to Microsoft Graph with required scopes for Intune management. .PARAMETER Scopes Graph API permission scopes to request for delegated auth. .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. .PARAMETER UseBrowserAuth Use InTUI's WAM-free PKCE loopback browser flow for delegated auth. .PARAMETER BrowserSuccessPurpose Browser auth success page purpose. .PARAMETER BrowserErrorPurpose Browser auth error page purpose. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Microsoft.Graph.Authentication requires a PSCredential for client secret authentication.')] [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, [Parameter()] [switch]$UseBrowserAuth, [Parameter()] [string]$BrowserSuccessPurpose = 'Authentication', [Parameter()] [string]$BrowserErrorPurpose = 'Authentication' ) $envConfig = $script:CloudEnvironments[$Environment] $useClientCredential = $ClientId -and $ClientSecret -and $TenantId try { $delegatedAuthMode = 'None' if ($useClientCredential) { Assert-InTUIGraphAuthMode -ClientCredential -UseDeviceCode:$UseDeviceCode -UseBrowserAuth:$UseBrowserAuth } else { $delegatedAuthMode = Resolve-InTUIGraphDelegatedAuthMode -UseDeviceCode:$UseDeviceCode -UseBrowserAuth:$UseBrowserAuth } $authMode = if ($useClientCredential) { 'ClientCredential' } else { $delegatedAuthMode } Write-InTUILog -Message "Connecting to Microsoft Graph" -Context @{ Environment = $Environment GraphBaseUrl = $envConfig.GraphBaseUrl TenantId = $TenantId AuthMode = $authMode } 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 { if ($delegatedAuthMode -eq 'BrowserAuth') { Connect-InTUIBrowserGraph -Scopes $Scopes -EnvironmentConfig $envConfig -TenantId $TenantId -ClientId $ClientId -SuccessPurpose $BrowserSuccessPurpose -ErrorPurpose $BrowserErrorPurpose } else { $params = @{ Scopes = $Scopes ContextScope = 'Process' NoWelcome = $true Environment = $envConfig.MgEnvironment } if ($TenantId) { $params['TenantId'] = $TenantId } $params['UseDeviceCode'] = $true if ($ClientId) { $params['ClientId'] = $ClientId } Connect-MgGraph @params } } $context = Get-MgContext if (-not $context) { return $false } $script:CloudEnvironment = $Environment $script:GraphBaseUrl = $envConfig.GraphBaseUrl $script:GraphBetaUrl = $envConfig.GraphBetaUrl $script:Connected = $true $script:UseDeviceCode = (-not $useClientCredential -and $delegatedAuthMode -eq 'DeviceCode') $script:UseBrowserAuth = (-not $useClientCredential -and $delegatedAuthMode -eq 'BrowserAuth') $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 { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Justification = 'Reconnect is the established internal verb for refreshing the active Graph session.')] [CmdletBinding()] param( [Parameter()] [string[]]$Scopes, [Parameter()] [string]$TenantId, [Parameter()] [ValidateSet('Global', 'USGov', 'USGovDoD', 'China')] [string]$Environment, [Parameter()] [switch]$UseDeviceCode, [Parameter()] [switch]$UseBrowserAuth, [Parameter()] [string]$BrowserSuccessPurpose = 'Authentication', [Parameter()] [string]$BrowserErrorPurpose = 'Authentication' ) $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 } if (-not $PSBoundParameters.ContainsKey('UseBrowserAuth') -and $script:UseBrowserAuth) { $UseBrowserAuth = [switch]$true } $delegatedAuthMode = Resolve-InTUIGraphDelegatedAuthMode -UseDeviceCode:$UseDeviceCode -UseBrowserAuth:$UseBrowserAuth Write-InTUILog -Message 'Reconnecting to Microsoft Graph' -Context @{ TenantId = $TenantId Environment = $Environment ScopeCount = $scopeList.Count AuthMode = $delegatedAuthMode } $connectParams = @{ Environment = $Environment } if (-not [string]::IsNullOrWhiteSpace($TenantId)) { $connectParams['TenantId'] = $TenantId } if ($scopeList.Count -gt 0) { $connectParams['Scopes'] = $scopeList } if ($delegatedAuthMode -eq 'DeviceCode') { $connectParams['UseDeviceCode'] = $true } if ($delegatedAuthMode -eq 'BrowserAuth') { $connectParams['UseBrowserAuth'] = $true $connectParams['BrowserSuccessPurpose'] = $BrowserSuccessPurpose $connectParams['BrowserErrorPurpose'] = $BrowserErrorPurpose } if ($delegatedAuthMode -ne 'BrowserAuth') { Disconnect-MgGraph -ErrorAction SilentlyContinue $script:Connected = $false } 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[/]' } } } |