Private/Invoke-SPCTempElevationInternal.ps1

function Invoke-SPCTempElevationInternal {
    <#
    .SYNOPSIS
        Elevates current interactive operator to Site Collection Administrator, Group Owner, or Site Permission Holder temporarily.
    .DESCRIPTION
        Implements multi-strategy elevation with fallback for SharePoint Online.
        Strategy 1: CSOM Set-PnPTenantSite -Identity (with both UPN and Claims login formats).
        Strategy 2: Tenant REST API /_api/SPO.Tenant/SetSiteAdmin.
        Strategy 3: Microsoft Graph API M365 Group Owner addition if site is connected to an M365 Group.
        Strategy 4: Microsoft Graph API Site Permission role addition (/sites/{siteId}/permissions).
    .OUTPUTS
        [PSCustomObject] Elevation record containing Success, ElevationType, SiteUrl, OperatorUPN, GroupId, PermissionId.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$SiteUrl,

        [Parameter()]
        [PSCustomObject]$Context = $script:SPCContext
    )

    begin {
        Write-Verbose "Invoke-SPCTempElevationInternal: Entering elevation process for site '$SiteUrl'"
        if ($null -eq $Context) {
            throw "ERR-ELEV-001: No active SPClean context provided for elevation."
        }
    }

    process {
        $authMode = if ($Context.AuthMode) { $Context.AuthMode } else { $Context.AuthMethod }
        if ($authMode -ne 'Interactive') {
            Write-Verbose "Invoke-SPCTempElevationInternal: Skipping elevation because auth mode is '$authMode' (AppOnly or non-Interactive)."
            return [PSCustomObject]@{
                Success       = $false
                ElevationType = 'None'
                SiteUrl       = $SiteUrl
                OperatorUPN   = $null
                GroupId       = $null
                PermissionId  = $null
                ErrorMessage  = "Elevation only applicable in Interactive mode."
            }
        }

        # Resolve Operator UPN and User Object ID
        $myUPN = $null
        $userOid = $null
        $graphHeaders = if ($Context.GraphAccessToken) {
            @{ Authorization = "Bearer $($Context.GraphAccessToken)"; 'Content-Type' = 'application/json' }
        } else {
            $null
        }

        if ($graphHeaders) {
            try {
                $me = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me?`$select=id,displayName,userPrincipalName" -Headers $graphHeaders -ErrorAction Stop
                $myUPN = $me.userPrincipalName
                $userOid = $me.id
            } catch {}
        }

        if (-not $myUPN) {
            if ($Context.OperatorUPN -and $Context.OperatorUPN -notlike 'AppOnly:*') {
                $myUPN = $Context.OperatorUPN
            } else {
                try {
                    $myUPN = (Get-MgContext).Account
                } catch {}
            }
        }

        if (-not $myUPN -and $Context.TenantName) {
            $myUPN = "admin@$($Context.TenantName).onmicrosoft.com"
        }

        if (-not $myUPN) {
            Write-Verbose "Invoke-SPCTempElevationInternal: Could not determine operator UPN."
            return [PSCustomObject]@{
                Success       = $false
                ElevationType = 'None'
                SiteUrl       = $SiteUrl
                OperatorUPN   = $null
                GroupId       = $null
                PermissionId  = $null
                ErrorMessage  = "Unable to determine current operator UPN for elevation."
            }
        }

        $claimsLogin = if ($myUPN -match "\|membership\|") { $myUPN } else { "i:0#.f|membership|$myUPN" }
        $errors = [System.Collections.Generic.List[string]]::new()

        # Strategy 1A & 1B: CSOM Tenant Site Owner (Set-PnPTenantSite -Identity)
        if ($Context.PnPContext) {
            try {
                Write-Verbose "Invoke-SPCTempElevationInternal: Strategy 1A - CSOM Set-PnPTenantSite (UPN: $myUPN)..."
                Set-PnPTenantSite -Connection $Context.PnPContext -Identity $SiteUrl -Owners $myUPN -ErrorAction Stop
                Start-Sleep -Seconds 5
                return [PSCustomObject]@{
                    Success       = $true
                    ElevationType = 'SCA'
                    SiteUrl       = $SiteUrl
                    OperatorUPN   = $myUPN
                    GroupId       = $null
                    PermissionId  = $null
                    ErrorMessage  = $null
                }
            }
            catch {
                $errors.Add("Strategy 1A (CSOM UPN): $($_.Exception.Message)")
            }

            try {
                Write-Verbose "Invoke-SPCTempElevationInternal: Strategy 1B - CSOM Set-PnPTenantSite (Claims: $claimsLogin)..."
                Set-PnPTenantSite -Connection $Context.PnPContext -Identity $SiteUrl -Owners $claimsLogin -ErrorAction Stop
                Start-Sleep -Seconds 5
                return [PSCustomObject]@{
                    Success       = $true
                    ElevationType = 'SCA'
                    SiteUrl       = $SiteUrl
                    OperatorUPN   = $myUPN
                    GroupId       = $null
                    PermissionId  = $null
                    ErrorMessage  = $null
                }
            }
            catch {
                $errors.Add("Strategy 1B (CSOM Claims): $($_.Exception.Message)")
            }

            # Strategy 2: Tenant REST API /_api/SPO.Tenant/SetSiteAdmin
            try {
                Write-Verbose "Invoke-SPCTempElevationInternal: Strategy 2 - REST /_api/SPO.Tenant/SetSiteAdmin..."
                $restBody = @{
                    siteUrl     = $SiteUrl
                    loginName   = $claimsLogin
                    isSiteAdmin = $true
                }
                $null = Invoke-PnPSPRestMethod -Method Post -Url "/_api/SPO.Tenant/SetSiteAdmin" -Content $restBody -Connection $Context.PnPContext -ErrorAction Stop
                Start-Sleep -Seconds 5
                return [PSCustomObject]@{
                    Success       = $true
                    ElevationType = 'REST_SCA'
                    SiteUrl       = $SiteUrl
                    OperatorUPN   = $myUPN
                    GroupId       = $null
                    PermissionId  = $null
                    ErrorMessage  = $null
                }
            }
            catch {
                $errors.Add("Strategy 2 (REST SPO.Tenant): $($_.Exception.Message)")
            }
        }

        # Strategy 3: Graph API M365 Group Owner Elevation
        if ($graphHeaders) {
            try {
                Write-Verbose "Invoke-SPCTempElevationInternal: Strategy 3 - Microsoft Graph M365 Group Owner..."
                $uri = [System.Uri]$SiteUrl
                $hostName = $uri.Host
                $sitePath = $uri.AbsolutePath.Trim('/')
                $alias = $sitePath.Split('/')[-1]

                if (-not $userOid) {
                    $userObj = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/$([System.Uri]::EscapeDataString($myUPN))?`$select=id" -Headers $graphHeaders -ErrorAction Stop
                    $userOid = $userObj.id
                }

                # Try finding group by alias or mailNickname
                $groupSearchResult = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=mailNickname eq '$alias' or displayName eq '$alias'&`$select=id" -Headers $graphHeaders -ErrorAction SilentlyContinue
                
                if ($groupSearchResult.value -and $groupSearchResult.value.Count -gt 0) {
                    $targetGroupId = $groupSearchResult.value[0].id
                    $body = @{
                        "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$userOid"
                    } | ConvertTo-Json

                    Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/groups/$targetGroupId/owners/`$ref" -Method Post -Headers $graphHeaders -Body $body -ErrorAction Stop
                    Start-Sleep -Seconds 5

                    return [PSCustomObject]@{
                        Success       = $true
                        ElevationType = 'GroupOwner'
                        SiteUrl       = $SiteUrl
                        OperatorUPN   = $myUPN
                        GroupId       = $targetGroupId
                        PermissionId  = $null
                        ErrorMessage  = $null
                    }
                }
            }
            catch {
                $errors.Add("Strategy 3 (Graph Group Owner): $($_.Exception.Message)")
            }

            # Strategy 4: Microsoft Graph Site Permissions API (/sites/{id}/permissions)
            try {
                Write-Verbose "Invoke-SPCTempElevationInternal: Strategy 4 - Microsoft Graph Site Permissions..."
                $uri = [System.Uri]$SiteUrl
                $hostName = $uri.Host
                $sitePath = $uri.AbsolutePath.Trim('/')

                $siteInfo = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/sites/${hostName}:/${sitePath}?`$select=id" -Headers $graphHeaders -ErrorAction Stop
                if ($siteInfo -and $siteInfo.id) {
                    $siteId = $siteInfo.id
                    if (-not $userOid) {
                        $userObj = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/$([System.Uri]::EscapeDataString($myUPN))?`$select=id,displayName,userPrincipalName" -Headers $graphHeaders -ErrorAction Stop
                        $userOid = $userObj.id
                    }
                    
                    $permBody = @{
                        roles = @("write", "owner")
                        grantedToIdentities = @(
                            @{
                                user = @{
                                    id                = $userOid
                                    userPrincipalName = $myUPN
                                }
                            }
                        )
                    } | ConvertTo-Json -Depth 5

                    $permRes = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/sites/$siteId/permissions" -Method Post -Headers $graphHeaders -Body $permBody -ErrorAction Stop
                    Start-Sleep -Seconds 5

                    return [PSCustomObject]@{
                        Success       = $true
                        ElevationType = 'GraphSitePerm'
                        SiteUrl       = $SiteUrl
                        OperatorUPN   = $myUPN
                        GroupId       = $siteId
                        PermissionId  = $permRes.id
                        ErrorMessage  = $null
                    }
                }
            }
            catch {
                $errors.Add("Strategy 4 (Graph Site Perm): $($_.Exception.Message)")
            }
        }

        # If all strategies failed, return structured failure object
        $details = $errors -join " | "
        return [PSCustomObject]@{
            Success       = $false
            ElevationType = 'None'
            SiteUrl       = $SiteUrl
            OperatorUPN   = $myUPN
            GroupId       = $null
            PermissionId  = $null
            ErrorMessage  = "All elevation strategies failed: $details"
        }
    }

    end {
        Write-Verbose "Invoke-SPCTempElevationInternal: Completed elevation attempt for '$SiteUrl'"
    }
}

function Undo-SPCTempElevationInternal {
    <#
    .SYNOPSIS
        Rolls back temporary elevation safely across all strategies.
    .PARAMETER ElevationRecord
        The PSCustomObject returned by Invoke-SPCTempElevationInternal.
    .PARAMETER SiteConnection
        The PnP Connection object for the site.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [PSCustomObject]$ElevationRecord,

        [Parameter()]
        [object]$SiteConnection,

        [Parameter()]
        [PSCustomObject]$Context = $script:SPCContext
    )

    if ($null -eq $ElevationRecord -or -not $ElevationRecord.Success) {
        return
    }

    try {
        switch ($ElevationRecord.ElevationType) {
            'SCA' {
                if ($ElevationRecord.OperatorUPN) {
                    Write-Verbose "Undo-SPCTempElevationInternal: Removing temporary Site Collection Admin for $($ElevationRecord.OperatorUPN) on $($ElevationRecord.SiteUrl)"
                    Remove-PnPSiteCollectionAdmin -Connection $SiteConnection -Owners $ElevationRecord.OperatorUPN -ErrorAction SilentlyContinue
                }
            }
            'REST_SCA' {
                if ($Context.PnPContext -and $ElevationRecord.OperatorUPN) {
                    Write-Verbose "Undo-SPCTempElevationInternal: Removing temporary REST Site Collection Admin for $($ElevationRecord.OperatorUPN) on $($ElevationRecord.SiteUrl)"
                    $claimsLogin = if ($ElevationRecord.OperatorUPN -match "\|membership\|") { $ElevationRecord.OperatorUPN } else { "i:0#.f|membership|$($ElevationRecord.OperatorUPN)" }
                    $restBody = @{
                        siteUrl     = $ElevationRecord.SiteUrl
                        loginName   = $claimsLogin
                        isSiteAdmin = $false
                    }
                    $null = Invoke-PnPSPRestMethod -Method Post -Url "/_api/SPO.Tenant/SetSiteAdmin" -Content $restBody -Connection $Context.PnPContext -ErrorAction SilentlyContinue
                }
            }
            'GroupOwner' {
                if ($ElevationRecord.GroupId -and $ElevationRecord.OperatorUPN -and $Context.GraphAccessToken) {
                    Write-Verbose "Undo-SPCTempElevationInternal: Removing temporary Group Owner from group $($ElevationRecord.GroupId)"
                    $graphHeaders = @{ Authorization = "Bearer $($Context.GraphAccessToken)" }
                    $userObj = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/$($ElevationRecord.OperatorUPN)?`$select=id" -Headers $graphHeaders -ErrorAction SilentlyContinue
                    if ($userObj -and $userObj.id) {
                        Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/groups/$($ElevationRecord.GroupId)/owners/$($userObj.id)/`$ref" -Method Delete -Headers $graphHeaders -ErrorAction SilentlyContinue
                    }
                }
            }
            'GraphSitePerm' {
                if ($ElevationRecord.GroupId -and $ElevationRecord.PermissionId -and $Context.GraphAccessToken) {
                    Write-Verbose "Undo-SPCTempElevationInternal: Removing temporary Graph Site Permission $($ElevationRecord.PermissionId) on site $($ElevationRecord.GroupId)"
                    $graphHeaders = @{ Authorization = "Bearer $($Context.GraphAccessToken)" }
                    Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/sites/$($ElevationRecord.GroupId)/permissions/$($ElevationRecord.PermissionId)" -Method Delete -Headers $graphHeaders -ErrorAction SilentlyContinue
                }
            }
        }
    }
    catch {
        Write-Verbose "Undo-SPCTempElevationInternal: Cleanup encountered non-fatal error: $($_.Exception.Message)"
    }
}