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)

            # Reset before reading — ensures pagination always terminates
            $nextLink = $null

            if ($null -ne $response) {
                # Collect items: some endpoints do not wrap results in a value array
                $items = Get-IdleEntraIDGraphResponseProperty -InputObject $response -PropertyName 'value'
                if ($null -ne $items) {
                    $allItems += $items
                }

                # Advance to next page when @odata.nextLink is present and non-empty
                $candidate = Get-IdleEntraIDGraphResponseProperty -InputObject $response -PropertyName '@odata.nextLink'
                if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) {
                    $nextLink = [string]$candidate
                }
            }
        }

        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)
            return $true
        }
        catch {
            # Idempotency: if already a member, treat as no-op
            if ($_.Exception.Message -match 'already exists|already a member') {
                return $false
            }
            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)
            return $true
        }
        catch {
            # Idempotency: if not a member, treat as no-op
            if ($_.Exception.Message -match '404|not found|does not exist') {
                return $false
            }
            throw
        }
    } -Force

    $adapter | Add-Member -MemberType ScriptMethod -Name BatchMembershipChanges -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object[]] $Operations,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $AccessToken
        )

        $results = [System.Collections.Generic.List[object]]::new()
        $batchSize = 20

        for ($i = 0; $i -lt $Operations.Count; $i += $batchSize) {
            $end = [Math]::Min($i + $batchSize - 1, $Operations.Count - 1)
            $batch = @($Operations[$i..$end])

            $requests = @()
            foreach ($op in $batch) {
                if ($op.Action -eq 'remove') {
                    $requests += @{
                        id     = $op.RequestId
                        method = 'DELETE'
                        url    = "/groups/$($op.GroupObjectId)/members/$($op.UserObjectId)/`$ref"
                    }
                }
                else {
                    $requests += @{
                        id      = $op.RequestId
                        method  = 'POST'
                        url     = "/groups/$($op.GroupObjectId)/members/`$ref"
                        headers = @{ 'Content-Type' = 'application/json' }
                        body    = @{ '@odata.id' = "$($this.BaseUri)/directoryObjects/$($op.UserObjectId)" }
                    }
                }
            }

            $batchUri = "$($this.BaseUri)/`$batch"
            $batchResponse = $this.InvokeGraphRequest('POST', $batchUri, $AccessToken, @{ requests = $requests })

            foreach ($resp in $batchResponse.responses) {
                $op = $batch | Where-Object { $_.RequestId -eq $resp.id }
                $changed = $false
                $errorMsg = $null

                if ($resp.status -ge 200 -and $resp.status -lt 300) {
                    $changed = $true
                }
                elseif ($resp.status -eq 404 -and $op.Action -eq 'remove') {
                    # Not a member — idempotent no-op
                    $changed = $false
                }
                elseif ($resp.status -eq 400 -and $op.Action -eq 'add') {
                    $msg = if ($resp.body -and $resp.body.error) { $resp.body.error.message } else { '' }
                    if ($msg -match 'already exists|already a member') {
                        $changed = $false
                    }
                    else {
                        $errorMsg = "HTTP $($resp.status): $msg"
                    }
                }
                else {
                    $msg = if ($resp.body -and $resp.body.error) { $resp.body.error.message } else { '' }
                    $errorMsg = "HTTP $($resp.status): $msg"
                }

                $results.Add([pscustomobject]@{
                        PSTypeName    = 'IdLE.BatchMembershipResult'
                        RequestId     = $resp.id
                        GroupObjectId = $op.GroupObjectId
                        Action        = $op.Action
                        Changed       = $changed
                        Error         = $errorMsg
                    })
            }
        }

        return @($results)
    } -Force

    $adapter | Add-Member -MemberType ScriptMethod -Name RevokeSignInSessions -Value {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $ObjectId,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $AccessToken
        )

        $uri = "$($this.BaseUri)/users/$ObjectId/revokeSignInSessions"

        $response = $this.InvokeGraphRequest('POST', $uri, $AccessToken, $null)
        # Graph returns { "@odata.context": "...", "value": true/false }
        # The value indicates whether sessions were revoked
        return $response
    } -Force

    return $adapter
}