Modules/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1
|
function New-IdleEntraIDAdapter { <# .SYNOPSIS Creates an internal adapter that wraps Microsoft Graph API operations. .DESCRIPTION This adapter provides a testable boundary between the provider and Graph API REST calls. Unit tests can inject a fake adapter without requiring a real Entra ID environment. The adapter uses direct REST calls to Microsoft Graph v1.0 endpoints for maximum portability. .PARAMETER BaseUri Base URI for Microsoft Graph API. Defaults to https://graph.microsoft.com/v1.0 #> [CmdletBinding()] param( [Parameter()] [string] $BaseUri = 'https://graph.microsoft.com/v1.0' ) $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.EntraIDAdapter' BaseUri = $BaseUri.TrimEnd('/') } # Helper to invoke Graph API with error handling $invokeGraphRequest = { param( [Parameter(Mandatory)] [string] $Method, [Parameter(Mandatory)] [string] $Uri, [Parameter(Mandatory)] [string] $AccessToken, [Parameter()] [object] $Body, [Parameter()] [string] $ContentType = 'application/json' ) $headers = @{ 'Authorization' = "Bearer $AccessToken" 'Content-Type' = $ContentType } $params = @{ Method = $Method Uri = $Uri Headers = $headers ErrorAction = 'Stop' } if ($null -ne $Body) { if ($Body -is [string]) { $params['Body'] = $Body } else { $params['Body'] = $Body | ConvertTo-Json -Depth 10 -Compress } } try { $response = Invoke-RestMethod @params return $response } catch { $statusCode = $null $requestId = $null $retryAfter = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode if ($_.Exception.Response.Headers) { $requestId = $_.Exception.Response.Headers['request-id'] $retryAfter = $_.Exception.Response.Headers['Retry-After'] } } # Classify transient errors $isTransient = $false if ($statusCode -ge 500 -or $statusCode -eq 429 -or $statusCode -eq 408) { $isTransient = $true } # Check for network/timeout errors if ($_.Exception.InnerException -is [System.Net.WebException] -or $_.Exception.Message -match 'timeout|timed out') { $isTransient = $true } # Build error message without exposing sensitive data $errorMessage = "Graph API request failed" if ($statusCode) { $errorMessage += " | Status: $statusCode" } if ($requestId) { $errorMessage += " | RequestId: $requestId" } if ($retryAfter) { $errorMessage += " | RetryAfter: $retryAfter" } # Do not include the full exception message as it might contain tokens or sensitive data # Only include safe error details $ex = [System.Exception]::new($errorMessage, $_.Exception) if ($isTransient) { $ex.Data['Idle.IsTransient'] = $true } throw $ex } } $adapter | Add-Member -MemberType ScriptMethod -Name InvokeGraphRequest -Value $invokeGraphRequest -Force # Helper to handle paging $getAllPages = { param( [Parameter(Mandatory)] [string] $Uri, [Parameter(Mandatory)] [string] $AccessToken ) $allItems = @() $nextLink = $Uri while ($null -ne $nextLink) { $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) if ($response.value) { $allItems += $response.value } $nextLink = $response.'@odata.nextLink' } return $allItems } $adapter | Add-Member -MemberType ScriptMethod -Name GetAllPages -Value $getAllPages -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users/$ObjectId" $uri += '?$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' try { $user = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) return $user } catch { if ($_.Exception.Message -match '404|not found|does not exist') { return $null } throw } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Upn, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) # URL encode the UPN for the filter $encodedUpn = [System.Net.WebUtility]::UrlEncode($Upn) $uri = "$($this.BaseUri)/users?`$filter=userPrincipalName eq '$encodedUpn'" $uri += '&$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' $users = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) if ($users.value -and $users.value.Count -gt 0) { return $users.value[0] } return $null } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Mail, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) # URL encode the mail for the filter $encodedMail = [System.Net.WebUtility]::UrlEncode($Mail) $uri = "$($this.BaseUri)/users?`$filter=mail eq '$encodedMail'" $uri += '&$select=id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,department,jobTitle,officeLocation,companyName' $users = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) if ($users.value -and $users.value.Count -gt 0) { return $users.value[0] } return $null } -Force $adapter | Add-Member -MemberType ScriptMethod -Name CreateUser -Value { param( [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Payload, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users" $user = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $Payload) return $user } -Force $adapter | Add-Member -MemberType ScriptMethod -Name PatchUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ObjectId, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Payload, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users/$ObjectId" $null = $this.InvokeGraphRequest('PATCH', $uri, $AccessToken, $Payload) } -Force $adapter | Add-Member -MemberType ScriptMethod -Name DeleteUser -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users/$ObjectId" $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) } -Force $adapter | Add-Member -MemberType ScriptMethod -Name ListUsers -Value { param( [Parameter()] [hashtable] $Filter, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users" $uri += '?$select=id,userPrincipalName,mail' if ($null -ne $Filter -and $Filter.ContainsKey('Search') -and -not [string]::IsNullOrWhiteSpace($Filter['Search'])) { $searchValue = [string]$Filter['Search'] $encodedSearch = [System.Net.WebUtility]::UrlEncode($searchValue) $uri += "&`$filter=startswith(userPrincipalName,'$encodedSearch') or startswith(displayName,'$encodedSearch')" } $users = $this.GetAllPages($uri, $AccessToken) return $users } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupById -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) # Try as GUID first $uri = "$($this.BaseUri)/groups/$GroupId" $uri += '?$select=id,displayName,mail,mailNickname' try { $group = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) return $group } catch { if ($_.Exception.Message -match '404|not found|does not exist') { return $null } throw } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name GetGroupByDisplayName -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $DisplayName, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $encodedName = [System.Net.WebUtility]::UrlEncode($DisplayName) $uri = "$($this.BaseUri)/groups?`$filter=displayName eq '$encodedName'" $uri += '&$select=id,displayName,mail,mailNickname' $groups = $this.InvokeGraphRequest('GET', $uri, $AccessToken, $null) if (-not $groups.value -or $groups.value.Count -eq 0) { return $null } if ($groups.value.Count -gt 1) { throw "Multiple groups found with displayName '$DisplayName'. Use objectId for deterministic lookup." } return $groups.value[0] } -Force $adapter | Add-Member -MemberType ScriptMethod -Name ListUserGroups -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/users/$ObjectId/memberOf" $uri += '?$select=id,displayName,mail' $groups = $this.GetAllPages($uri, $AccessToken) return $groups } -Force $adapter | Add-Member -MemberType ScriptMethod -Name AddGroupMember -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $UserObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/groups/$GroupObjectId/members/`$ref" $body = @{ '@odata.id' = "$($this.BaseUri)/directoryObjects/$UserObjectId" } try { $null = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $body) } catch { # Idempotency: if already a member, treat as success if ($_.Exception.Message -match 'already exists|already a member') { return } throw } } -Force $adapter | Add-Member -MemberType ScriptMethod -Name RemoveGroupMember -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $UserObjectId, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $AccessToken ) $uri = "$($this.BaseUri)/groups/$GroupObjectId/members/$UserObjectId/`$ref" try { $null = $this.InvokeGraphRequest('DELETE', $uri, $AccessToken, $null) } catch { # Idempotency: if not a member, treat as success if ($_.Exception.Message -match '404|not found|does not exist') { return } throw } } -Force return $adapter } |