Private/Test-GkConnection.ps1
|
function Test-GkConnection { <# .SYNOPSIS Pre-flight validation before a public function makes Graph calls: confirms an active session, the required scopes (by capability group), and delegated-only constraints. .DESCRIPTION Throws an actionable, terminating error when the caller is not connected, is missing a required scope, or is app-only against a delegated-only function. On success, returns the IAuthContext. Called at the top of every public function. Scope checking uses capability groups from $script:GkScopeMap: the caller must hold at least one scope from each group, so a broad scope (Directory.Read.All) satisfies narrower needs without a false failure. A function whose scope requirement depends on what it was asked to do (e.g. Remove-GkStaleGuest disabling vs deleting) passes -Variant; the map is then keyed '<FunctionName>:<Variant>', falling back to '<FunctionName>' when no such entry exists. Public functions pass -Caller $PSCmdlet so the terminating error is raised from the cmdlet the user actually typed. Without it the failure is attributed to this private helper, and PowerShell's ConciseView renders a code frame pointing into Test-GkConnection.ps1, which reads like a leaked stack trace. .OUTPUTS Microsoft.Graph.PowerShell.Authentication.IAuthContext #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $FunctionName, # Selects an action-specific scope entry; ignored when the map has no variant key. [string] $Variant, # The calling public cmdlet's $PSCmdlet, so errors are attributed to it rather than here. [System.Management.Automation.PSCmdlet] $Caller ) # Raise from the caller when we were given one, so the error names the user's cmdlet. $thrower = $PSCmdlet if ($Caller) { $thrower = $Caller } $ctx = Get-MgContext $mapKey = Resolve-GkScopeMapKey -FunctionName $FunctionName -Variant $Variant if (-not $ctx) { $ex = [System.Exception]::new( "Not connected to Microsoft Graph. Run: $(Get-GkConnectCommandHint -FunctionName $FunctionName -Variant $Variant)") $er = [System.Management.Automation.ErrorRecord]::new( $ex, 'GkNotConnected', [System.Management.Automation.ErrorCategory]::AuthenticationError, $FunctionName) $thrower.ThrowTerminatingError($er) } if (-not $mapKey) { # Unknown function name — nothing to validate beyond an active session. return $ctx } $entry = $script:GkScopeMap[$mapKey] # Auth-type constraint: delegated-only APIs cannot be served app-only. if ($entry.DelegatedOnly -and $ctx.AuthType -eq 'AppOnly') { $ex = [System.Exception]::new( "$FunctionName requires a delegated (interactive) session — it reads a Graph API with no application permission (e.g. licenseDetails). You are connected app-only. Reconnect with: $(Get-GkConnectCommandHint -FunctionName $FunctionName -Variant $Variant)") $er = [System.Management.Automation.ErrorRecord]::new( $ex, 'GkDelegatedOnly', [System.Management.Automation.ErrorCategory]::PermissionDenied, $FunctionName) $thrower.ThrowTerminatingError($er) } # Scope capability groups: need at least one scope from each group. $granted = @($ctx.Scopes) $missingGroups = foreach ($group in $entry.Groups) { $has = $false foreach ($s in $group.Any) { if ($granted -contains $s) { $has = $true; break } } if (-not $has) { $group } } if ($missingGroups) { $detail = ($missingGroups | ForEach-Object { "to $($_.For): one of [$($_.Any -join ', ')]" }) -join '; ' $ex = [System.Exception]::new( "Missing Graph scope(s) for ${FunctionName}: $detail. Run: $(Get-GkConnectCommandHint -FunctionName $FunctionName -Variant $Variant)") $er = [System.Management.Automation.ErrorRecord]::new( $ex, 'GkMissingScope', [System.Management.Automation.ErrorCategory]::PermissionDenied, $FunctionName) $thrower.ThrowTerminatingError($er) } return $ctx } function Resolve-GkScopeMapKey { <# .SYNOPSIS Resolve the $script:GkScopeMap key for a function and optional action variant. .DESCRIPTION Prefers the action-specific '<FunctionName>:<Variant>' entry and falls back to the plain '<FunctionName>' entry. Returns $null when neither is mapped. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $FunctionName, [string] $Variant ) if ($Variant) { $keyed = "${FunctionName}:${Variant}" if ($script:GkScopeMap.ContainsKey($keyed)) { return $keyed } } if ($script:GkScopeMap.ContainsKey($FunctionName)) { return $FunctionName } return $null } function Get-GkConnectCommandHint { <# .SYNOPSIS Build the remediation instruction shown when a connection/scope pre-flight fails. .DESCRIPTION Leads with Connect-GkGraph -ForCommand, which derives the scope set from the scope map so the user never hand-assembles scopes, and keeps the equivalent raw Connect-MgGraph call as a secondary note for anyone who connects their own way. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $FunctionName, [string] $Variant ) $scopes = Get-GkConnectScopeHint -FunctionName $FunctionName -Variant $Variant return "Connect-GkGraph -ForCommand $FunctionName (or connect your own way with: Connect-MgGraph -Scopes $scopes)" } function Get-GkConnectScopeHint { <# .SYNOPSIS Build a comma-joined least-effort scope string for a Connect-MgGraph hint: the first (least-privileged, as ordered in the map) scope from each capability group. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [string] $FunctionName, [string] $Variant ) $mapKey = Resolve-GkScopeMapKey -FunctionName $FunctionName -Variant $Variant if (-not $mapKey) { return 'User.Read.All' } $entry = $script:GkScopeMap[$mapKey] $scopes = foreach ($group in $entry.Groups) { @($group.Any)[0] } $scopes = @($scopes | Where-Object { $_ } | Select-Object -Unique) if (-not $scopes) { return 'User.Read' } return ($scopes -join ',') } |