ServiceDesk Plus Cloud: merge “fai-da-te” dei richiedenti

| |

Alcuni degli indirizzi di posta elettronica gestiti e inseriti tra i richiedenti del ServiceDesk Plus Cloud aziendale hanno subito un aggiornamento (migrazione del dominio, NdR), questo ha generato dei doppioni lasciando che le richieste rimanessero a storico degli account “sbagliati“. Lo scenario è quindi molto semplice, c’è il vecchio indirizzo / richiedente (solo per fare un esempio userò a.rossi@example.org il nuovo (che basandomi sul precedente esempio, potrebbe allora essere alberto.rossi@example.org).

Stessa persona, due ID diversi, e lo storico dei ticket rimasto legato alla vecchia identità, come già scritto qualche parola fa.

Nella versione Cloud di ServiceDesk Plus non ho trovato – almeno in interfaccia grafica – niente di equivalente al Merge User che avevo e sfruttavo (e sfrutto ancora, in realtà) con le installazioni on-prem. Ho dato una sbirciata alla documentazione e messo in moto ChatGPT per darmi una mano con le API v3 di SDP in Cloud, passando da PowerShell per facilitarmi la vita.

Perciò, lista della spesa:

  1. identificare e mappare correttamente vecchio e nuovo richiedente;
  2. verificare i  ticket legati al vecchio account;
  3. spostarli / riassegnarli al nuovo;
  4. controllare che sul vecchio non rimanga più nulla;
  5. eliminare l’account non più necessario;
  6. ripetere la stessa procedura sugli altri utenti, in blocco.

Qui di seguito trovi solo degli esempi: domini, utenti, ID, URL del portale e credenziali vanno sostituiti con quelli che appartengono alla tua installazione. Prima di partire sul serio, però, è d’obbligo rispolverare il messaggio di servizio:

Attenzione

Da qui in poi scenderò molto nel dettaglio, ci saranno snippet, script, riferimenti e tecnicismi in abbondanza.

Se non hai la minima idea di cosa sto parlando, abbandona la nave prima che sia troppo tardi e non leggere l’articolo, non voglio farti addormentare ovunque tu ti trovi, e non voglio neanche che tu pensi che sono una brutta persona più di quanto io già lo sia, salvati finché sei in tempo.

Il mio ambiente

Ho usato PowerShell 7 e un Self Client OAuth creato nella Zoho API Console del proprio data center.
Per il data center europeo, gli endpoint sono questi:

https://accounts.zoho.eu
https://sdpondemand.manageengine.eu

Nel mio caso il portale ServiceDesk Plus è:

https://sdpondemand.manageengine.eu/app/itdesk

itdesk è solo un esempio (anche se spesso corrisponde alla realtà, è il predefinito suggerito da ServiceDesk Plus Cloud per la tua prima installazione / istanza di SDP), tu sostituiscilo con il nome reale del tuo portale (o mantienilo se non lo hai mai cambiato).

Scope OAuth

Per l’intero processo ho autorizzato gli scope necessari a lettura utenti, lettura/modifica ticket e cancellazione richiedenti.
Gli scope che ti servono davvero dipendono da cosa vuoi fare: nel mio caso il Self Client copriva lettura, aggiornamento e cancellazione, che poi è quello che usano gli script che ti propongo qui di seguito.

Da Grant a Refresh Token

Il Grant Token generato dal Self Client è temporaneo e monouso.
Va scambiato – manco fosse una figurina dei calciatori – con un Refresh Token, perché questo lo puoi conservare e riutilizzare per far generare agli script nuovi Access Token in autonomia.

get-refreshtoken.ps1:

#requires -Version 7.0

# OAuth Self Client credentials
$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$GrantToken   = "YOUR_GRANT_TOKEN"

$Body = @{
    grant_type    = "authorization_code"
    client_id     = $ClientId
    client_secret = $ClientSecret
    code          = $GrantToken
}

$Response = Invoke-RestMethod `
    -Method Post `
    -Uri "https://accounts.zoho.eu/oauth/v2/token" `
    -ContentType "application/x-www-form-urlencoded" `
    -Body $Body

$Response

$RefreshToken = $Response.refresh_token

if ($RefreshToken) {
    Write-Host "Refresh token successfully obtained." -ForegroundColor Green
}
else {
    Write-Host "No refresh token returned." -ForegroundColor Red
}

Il valore da conservare è – mi ripeto – refresh_token. Il Grant Token non serve più dopo lo scambio, mentre l’Access Token è temporaneo e viene rigenerato al volo usando il Refresh Token.

Occhio: Client Secret, Grant Token, Access Token e Refresh Token sono a tutti gli effetti credenziali. Non lasciarli sul disco se non ti servono, non perderne il controllo.

1. Verifica i due richiedenti

Prima di toccare qualsiasi dato, ti conviene verificare l’esistenza delle due identità e contare i ticket associati a ciascuna, così cerchi di capire se tutto ti torna rispetto a una semplice ricerca (identica nel risultato finale) via interfaccia grafica di SDP (ti basta fare una ricerca avanzata nella schermata delle richieste includendo i filtri adatti a mostrarti quanti ticket appartengono al richiedente vecchio).

check-merge.ps1:

#requires -Version 7.0

<#
.SYNOPSIS
    Audits two ServiceDesk Plus Cloud requester accounts.

.DESCRIPTION
    The script:
    - Generates a fresh OAuth access token using a refresh token
    - Finds two SDP users by email address
    - Retrieves requests associated with each requester
    - Displays a comparison
    - Makes no changes to ServiceDesk Plus
#>

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldEmail = "a.rossi@example.org"
$NewEmail = "alberto.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access_token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri,

        [hashtable]$Query
    )

    if ($Query) {
        $QueryString = (
            $Query.GetEnumerator() |
            ForEach-Object {
                "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString([string]$_.Value))"
            }
        ) -join "&"

        $Uri = "$Uri`?$QueryString"
    }

    return Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders
}

function Get-SdpUserByEmail {
    param (
        [Parameter(Mandatory)]
        [string]$EmailAddress
    )

    $InputData = @{
        list_info = @{
            row_count       = 100
            start_index     = 1
            get_total_count = $true
            search_fields   = @{
                email_id = $EmailAddress
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Response = Invoke-SdpGet `
        -Uri "$BaseUrl/api/v3/users" `
        -Query @{
            input_data = $InputData
        }

    if (-not $Response.users) {
        return $null
    }

    return $Response.users |
        Where-Object {
            $_.email_id -ieq $EmailAddress
        } |
        Select-Object -First 1
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()

    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests" `
            -Query @{
                input_data = $InputData
            }

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Show-SdpUser {
    param (
        [Parameter(Mandatory)]
        [string]$Label,

        [Parameter(Mandatory)]
        $User,

        [Parameter(Mandatory)]
        $Requests
    )

    Write-Host ""
    Write-Host "===== $Label =====" -ForegroundColor Cyan

    [PSCustomObject]@{
        ID           = $User.id
        Name         = $User.name
        Email        = $User.email_id
        LoginName    = $User.login_name
        Site         = $User.site.name
        Department   = $User.department.name
        JobTitle     = $User.jobtitle
        IsTechnician = $User.is_technician
        Requests     = $Requests.Count
    } | Format-List
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "OAuth token obtained." -ForegroundColor Green

$OldUser = Get-SdpUserByEmail -EmailAddress $OldEmail

if (-not $OldUser) {
    throw "Old requester not found: $OldEmail"
}

$NewUser = Get-SdpUserByEmail -EmailAddress $NewEmail

if (-not $NewUser) {
    throw "New requester not found: $NewEmail"
}

$OldRequests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUser.id
)

$NewRequests = @(
    Get-SdpRequestsByRequester -RequesterId $NewUser.id
)

Show-SdpUser `
    -Label "OLD USER" `
    -User $OldUser `
    -Requests $OldRequests

Show-SdpUser `
    -Label "NEW USER" `
    -User $NewUser `
    -Requests $NewRequests

$Summary = @(
    foreach ($Request in $OldRequests) {
        [PSCustomObject]@{
            Account     = "OLD"
            RequestId   = $Request.id
            Subject     = $Request.subject
            Status      = $Request.status.name
            Requester   = $Request.requester.name
            Email       = $Request.requester.email_id
            CreatedTime = $Request.created_time.display_value
        }
    }

    foreach ($Request in $NewRequests) {
        [PSCustomObject]@{
            Account     = "NEW"
            RequestId   = $Request.id
            Subject     = $Request.subject
            Status      = $Request.status.name
            Requester   = $Request.requester.name
            Email       = $Request.requester.email_id
            CreatedTime = $Request.created_time.display_value
        }
    }
)

$Summary |
    Sort-Object Account, RequestId |
    Format-Table -AutoSize

Write-Host ""
Write-Host "Audit completed. No changes were made." -ForegroundColor Yellow

Nel mio test il risultato era concettualmente questo:

OLD USER
Email    : a.rossi@example.org
Requests : 2

NEW USER
Email    : alberto.rossi@example.org
Requests : 0

Questo ti permette di capire quale ID far sopravvivere.

2. Sposta i ticket sull’utente ‘nuovo‘

Il secondo script usa l’ID del nuovo richiedente per aggiornare il campo requester dei ticket ancora legati alla vecchia identità. Quando lo lanci senza parametri parte sempre in simulazione così da evitare di modificare dati e fare potenziali danni, le modifiche scattano solo includendo -Apply.

move-sdp-requests.ps1:

#requires -Version 7.0

param (
    [switch]$Apply
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldUserId = "OLD_REQUESTER_ID"
$NewUserId = "NEW_REQUESTER_ID"

$OldEmail = "a.rossi@example.org"
$NewEmail = "alberto.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)
        $Uri = "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        $Response = Invoke-RestMethod `
            -Uri $Uri `
            -Method Get `
            -Headers $Script:SdpHeaders

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Set-SdpRequestRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequestId,

        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $InputData = @{
        request = @{
            requester = @{
                id = $RequesterId
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Body = @{
        input_data = $InputData
    }

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requests/$RequestId" `
        -Method Put `
        -Headers $Script:SdpHeaders `
        -ContentType "application/x-www-form-urlencoded" `
        -Body $Body
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

$Requests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

Write-Host "Found $($Requests.Count) request(s)." -ForegroundColor Cyan

$Requests |
    Select-Object `
        id,
        subject,
        @{Name = "Status"; Expression = { $_.status.name } },
        @{Name = "Requester"; Expression = { $_.requester.email_id } } |
    Format-Table -AutoSize

if (-not $Apply) {
    Write-Host ""
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes were made."
    Write-Host ""
    Write-Host "To apply the changes, run:"
    Write-Host ".\move-sdp-requests.ps1 -Apply"
    exit
}

foreach ($Request in $Requests) {
    Write-Host "Updating request $($Request.id): $($Request.subject)"

    $Result = Set-SdpRequestRequester `
        -RequestId $Request.id `
        -RequesterId $NewUserId

    if ($Result.response_status.status -eq "success") {
        Write-Host "  Success" -ForegroundColor Green
    }
    else {
        Write-Host "  API returned an unexpected status." -ForegroundColor Yellow
        $Result.response_status | Format-List
    }
}

Write-Host ""
Write-Host "Verifying remaining requests on old requester..." -ForegroundColor Yellow

$RemainingRequests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

if ($RemainingRequests.Count -eq 0) {
    Write-Host "Old requester has no remaining requests." -ForegroundColor Green
}
else {
    Write-Host "$($RemainingRequests.Count) request(s) still reference the old requester." -ForegroundColor Red
}

Prima:

.\move-sdp-requests.ps1

Dopo (dopo aver controllato l’elenco):

.\move-sdp-requests.ps1 -Apply

Il controllo finale qui è fondamentale: il vecchio richiedente non deve più avere ticket aperti a suo nome, altrimenti fermati e non andare avanti, c’è qualcosa di sbagliato o presumibilmente ‘saltato‘ durante i precedenti passaggi.
Ritenta, sarai più fortunato.

3. Controlla il richiedente ‘vecchio‘

A questo punto fai un controllo (quindi anche stavolta in sola lettura) sul vecchio richiedente.
Più uno scrupolo che altro.

audit-sdp-requester.ps1:

#requires -Version 7.0

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$OldUserId = "OLD_REQUESTER_ID"
$OldEmail  = "a.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri
    )

    Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "Auditing old requester:" -ForegroundColor Cyan
Write-Host "$OldEmail [$OldUserId]"

$UserResponse = Invoke-SdpGet `
    -Uri "$BaseUrl/api/v3/users/$OldUserId"

$User = $UserResponse.user

[PSCustomObject]@{
    ID         = $User.id
    Name       = $User.name
    Email      = $User.email_id
    Site       = $User.site.name
    Department = $User.department.name
} | Format-List

$Requests = @(
    Get-SdpRequestsByRequester -RequesterId $OldUserId
)

Write-Host ""
Write-Host "Requests: $($Requests.Count)"

if ($Requests.Count -eq 0) {
    Write-Host "No requests remain associated with the old requester." -ForegroundColor Green
}

Write-Host ""
Write-Host "Audit completed. No changes were made." -ForegroundColor Yellow

Per eventuali associazioni non esposte da questa API vale la pena fare anche un controllo dall’interfaccia grafica di SDP, tramite View Associations, prima della cancellazione definitiva.

4. Elimina il vecchio richiedente

Una volta verificato che l’identità vecchia non serve più, puoi eliminata tramite l’endpoint /api/v3/requesters/{id}. Anche questo script parte in sola lettura fino a quando non lo rilanci con il parametro per applicare davvero la modifica.

delete-sdp-requester.ps1:

#requires -Version 7.0

param (
    [switch]$Apply
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$RequesterId    = "OLD_REQUESTER_ID"
$RequesterEmail = "a.rossi@example.org"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded"

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Headers = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

Write-Host "Requester scheduled for deletion:"
Write-Host "$RequesterEmail [$RequesterId]"

if (-not $Apply) {
    Write-Host ""
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes were made."
    Write-Host ""
    Write-Host "To delete the requester, run:"
    Write-Host ".\delete-sdp-requester.ps1 -Apply" -ForegroundColor Cyan
    exit
}

$Response = Invoke-RestMethod `
    -Uri "$BaseUrl/api/v3/requesters/$RequesterId" `
    -Method Delete `
    -Headers $Headers `
    -ErrorAction Stop

Write-Host ""
Write-Host "Requester deleted successfully." -ForegroundColor Green

if ($Response) {
    $Response | ConvertTo-Json -Depth 10
}

Prima:

.\delete-sdp-requester.ps1

e solo dopo aver verificato che va a eliminare chi ti aspetti, lo rilanci:

.\delete-sdp-requester.ps1 -Apply

A questo punto il hai concluso, sei passato al nuovo richiedente e fatto piazza pulita del vecchio.

5. Ora ripeti, ma con tutti gli altri

Quando il giro per singolo utente ha sortito l’effetto desiderato sono passato a farlo per gli altri tutti insieme. In una migrazione reale ci sono sempre eccezioni, alias e omonimie che rovinano qualsiasi logica automatica, quindi valuta bene quello che fai, non pensare di dare tutto in pasto agli script, non presidiare e andarti a prendere un caffè sperando di non trovare sbavature al rientro.

Per la sicurezza e tranquillità della tua pausa caffè, preparati un file di mappatura in un CSV, così facendo hai la certezza di mettere vecchio e nuovo richiedente sul giusto livello, senza scherzi, senza “dedurre il nuovo dal vecchio”.

requester-mapping.csv:

OldEmail,NewEmail
a.verdi@example.org,andrea.verdi@example.org
d.bianchi@example.org,daniela.bianchi@example.org
e.neri@example.org,eleonora.neri@example.org
f.gialli@example.org,fabio.gialli@example.org

Script “bulk” (in un colpo solo)

bulk-merge-sdp-requesters.ps1:

#requires -Version 7.0

param (
    [Parameter()]
    [string]$MappingFile = ".\requester-mapping.csv",

    [switch]$Apply,

    [switch]$DeleteOld
)

$ClientId     = "YOUR_CLIENT_ID"
$ClientSecret = "YOUR_CLIENT_SECRET"
$RefreshToken = "YOUR_REFRESH_TOKEN"

$AccountsUrl = "https://accounts.zoho.eu"
$BaseUrl     = "https://sdpondemand.manageengine.eu/app/itdesk"

$Timestamp  = Get-Date -Format "yyyyMMdd-HHmmss"
$ReportFile = ".\sdp-merge-report-$Timestamp.csv"

function Get-SdpAccessToken {
    $Body = @{
        refresh_token = $RefreshToken
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = "refresh_token"
    }

    $Response = Invoke-RestMethod `
        -Uri "$AccountsUrl/oauth/v2/token" `
        -Method Post `
        -Body $Body `
        -ContentType "application/x-www-form-urlencoded" `
        -ErrorAction Stop

    if (-not $Response.access_token) {
        throw "OAuth response did not contain an access token."
    }

    return $Response.access_token
}

function Invoke-SdpGet {
    param (
        [Parameter(Mandatory)]
        [string]$Uri
    )

    Invoke-RestMethod `
        -Uri $Uri `
        -Method Get `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

function Get-SdpUserByEmail {
    param (
        [Parameter(Mandatory)]
        [string]$EmailAddress
    )

    $InputData = @{
        list_info = @{
            row_count       = 100
            start_index     = 1
            get_total_count = $true
            search_fields   = @{
                email_id = $EmailAddress
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $EncodedInputData = [uri]::EscapeDataString($InputData)

    $Response = Invoke-SdpGet `
        -Uri "$BaseUrl/api/v3/users?input_data=$EncodedInputData"

    $Matches = @(
        $Response.users |
        Where-Object {
            $_.email_id -ieq $EmailAddress
        }
    )

    if ($Matches.Count -eq 0) {
        return $null
    }

    if ($Matches.Count -gt 1) {
        throw "Multiple SDP users found with email address $EmailAddress."
    }

    return $Matches[0]
}

function Get-SdpRequestsByRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $AllRequests = [System.Collections.Generic.List[object]]::new()
    $StartIndex = 1
    $RowCount   = 100

    do {
        $InputData = @{
            list_info = @{
                row_count       = $RowCount
                start_index     = $StartIndex
                get_total_count = $true
                search_criteria = @{
                    field     = "requester.id"
                    condition = "is"
                    value     = $RequesterId
                }
            }
        } | ConvertTo-Json -Depth 10 -Compress

        $EncodedInputData = [uri]::EscapeDataString($InputData)

        $Response = Invoke-SdpGet `
            -Uri "$BaseUrl/api/v3/requests?input_data=$EncodedInputData"

        if ($Response.requests) {
            foreach ($Request in $Response.requests) {
                $AllRequests.Add($Request)
            }
        }

        $HasMoreRows = [bool]$Response.list_info.has_more_rows
        $StartIndex += $RowCount

    } while ($HasMoreRows)

    return $AllRequests
}

function Set-SdpRequestRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequestId,

        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    $InputData = @{
        request = @{
            requester = @{
                id = $RequesterId
            }
        }
    } | ConvertTo-Json -Depth 10 -Compress

    $Body = @{
        input_data = $InputData
    }

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requests/$RequestId" `
        -Method Put `
        -Headers $Script:SdpHeaders `
        -ContentType "application/x-www-form-urlencoded" `
        -Body $Body `
        -ErrorAction Stop
}

function Remove-SdpRequester {
    param (
        [Parameter(Mandatory)]
        [string]$RequesterId
    )

    Invoke-RestMethod `
        -Uri "$BaseUrl/api/v3/requesters/$RequesterId" `
        -Method Delete `
        -Headers $Script:SdpHeaders `
        -ErrorAction Stop
}

if (-not (Test-Path $MappingFile)) {
    throw "Mapping file not found: $MappingFile"
}

$Mappings = @(Import-Csv $MappingFile)

if ($Mappings.Count -eq 0) {
    throw "Mapping file contains no entries."
}

foreach ($Mapping in $Mappings) {
    if (-not $Mapping.OldEmail -or -not $Mapping.NewEmail) {
        throw "Every CSV row must contain OldEmail and NewEmail."
    }

    if ($Mapping.OldEmail -ieq $Mapping.NewEmail) {
        throw "OldEmail and NewEmail are identical: $($Mapping.OldEmail)"
    }
}

Write-Host ""
Write-Host "Generating OAuth access token..." -ForegroundColor Yellow

$AccessToken = Get-SdpAccessToken

$Script:SdpHeaders = @{
    Authorization = "Zoho-oauthtoken $AccessToken"
    Accept        = "application/vnd.manageengine.sdp.v3+json"
}

if (-not $Apply) {
    Write-Host "SIMULATION MODE" -ForegroundColor Yellow
    Write-Host "No changes will be made."
}
elseif (-not $DeleteOld) {
    Write-Host "APPLY MODE" -ForegroundColor Cyan
    Write-Host "Requests will be reassigned."
    Write-Host "Old requester accounts will NOT be deleted."
}
else {
    Write-Host "APPLY + DELETE MODE" -ForegroundColor Red
    Write-Host "Requests will be reassigned and empty old requester accounts will be deleted."
}

$Results = [System.Collections.Generic.List[object]]::new()
$Index = 0

foreach ($Mapping in $Mappings) {
    $Index++

    $OldEmail = $Mapping.OldEmail.Trim()
    $NewEmail = $Mapping.NewEmail.Trim()

    Write-Host ""
    Write-Host "============================================================"
    Write-Host "[$Index/$($Mappings.Count)] $OldEmail"
    Write-Host "                     -> $NewEmail"
    Write-Host "============================================================"

    $Result = [ordered]@{
        OldEmail          = $OldEmail
        NewEmail          = $NewEmail
        OldUserId         = $null
        NewUserId         = $null
        RequestsFound     = 0
        RequestsMoved     = 0
        RequestsRemaining = $null
        OldUserDeleted    = $false
        Status            = $null
        Error             = $null
    }

    try {
        $OldUser = Get-SdpUserByEmail -EmailAddress $OldEmail

        if (-not $OldUser) {
            $Result.Status = "Old user not found"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Result.OldUserId = $OldUser.id

        $NewUser = Get-SdpUserByEmail -EmailAddress $NewEmail

        if (-not $NewUser) {
            $Result.Status = "New user not found"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Result.NewUserId = $NewUser.id

        if ($OldUser.id -eq $NewUser.id) {
            $Result.Status = "Same SDP user"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        $Requests = @(
            Get-SdpRequestsByRequester -RequesterId $OldUser.id
        )

        $Result.RequestsFound = $Requests.Count

        if ($Requests.Count -gt 0) {
            $Requests |
                Select-Object `
                    id,
                    subject,
                    @{Name = "Status"; Expression = { $_.status.name } } |
                Format-Table -AutoSize
        }

        if (-not $Apply) {
            $Result.Status = "Simulation"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        foreach ($Request in $Requests) {
            Write-Host "Moving request $($Request.id)..." -NoNewline

            $Response = Set-SdpRequestRequester `
                -RequestId $Request.id `
                -RequesterId $NewUser.id

            if ($Response.response_status.status -eq "success") {
                $Result.RequestsMoved++
                Write-Host " OK" -ForegroundColor Green
            }
            else {
                throw "Unexpected API status while moving request $($Request.id)."
            }
        }

        $RemainingRequests = @(
            Get-SdpRequestsByRequester -RequesterId $OldUser.id
        )

        $Result.RequestsRemaining = $RemainingRequests.Count

        if ($RemainingRequests.Count -gt 0) {
            $Result.Status = "Requests remain"
            $Results.Add([PSCustomObject]$Result)
            continue
        }

        if ($DeleteOld) {
            Write-Host "Deleting old requester..." -NoNewline

            Remove-SdpRequester -RequesterId $OldUser.id

            Write-Host " OK" -ForegroundColor Green

            $Result.OldUserDeleted = $true
            $Result.Status = "Merged and deleted"
        }
        else {
            $Result.Status = "Requests merged"
        }
    }
    catch {
        Write-Host ""
        Write-Host "FAILED: $($_.Exception.Message)" -ForegroundColor Red

        $Result.Status = "Failed"
        $Result.Error  = $_.Exception.Message
    }

    $Results.Add([PSCustomObject]$Result)
}

$Results |
    Export-Csv `
        -Path $ReportFile `
        -NoTypeInformation `
        -Encoding UTF8

Write-Host ""
Write-Host "============================================================"
Write-Host "SUMMARY" -ForegroundColor Cyan
Write-Host "============================================================"

$Results |
    Format-Table `
        OldEmail,
        NewEmail,
        RequestsFound,
        RequestsMoved,
        RequestsRemaining,
        OldUserDeleted,
        Status `
        -AutoSize

Write-Host ""
Write-Host "Report:"
Write-Host $ReportFile -ForegroundColor Cyan

Consiglio spassionato

La modalità più prudente è affrontarla in tre passaggi.

1. Sola lettura, pura simulazione

.\bulk-merge-sdp-requesters.ps1

Non modifica nulla. Verifica tutte le corrispondenze e mostra i ticket trovati, così puoi ancora verificare che tutto corrisponda, in sola lettura, senza danni collaterali.

2. Spostamento dei ticket

.\bulk-merge-sdp-requesters.ps1 -Apply

Sposta i ticket, ma lascia intatti i vecchi richiedenti.

3. Eliminazione delle vecchie identità

Dopo aver verificato il report:

.\bulk-merge-sdp-requesters.ps1 -Apply -DeleteOld

Lo script ricontrolla che il vecchio richiedente non abbia più ticket prima di eliminarlo. Al termine viene mostrato (e salvato) in automatico un report CSV simile a questo:

OldEmail              NewEmail                     RequestsFound RequestsMoved RequestsRemaining OldUserDeleted Status
--------              --------                     ------------- ------------- ----------------- -------------- ------
a.verdi@example.org    andrea.verdi@example.org                 7             7                 0           True Merged and deleted
d.bianchi@example.org  daniela.bianchi@example.org              3             3                 0           True Merged and deleted
e.neri@example.org     eleonora.neri@example.org                0             0                 0           True Merged and deleted

In conclusione

La parte più importante di questa procedura non è la DELETE, è tutto quello che viene prima. Cancellare gli utenti è solo l’operazione ultima per non lasciare sporco in giro. Un utente di ServiceDesk Plus non lo elimini semplicemente perché il suo indirizzo e-mail è diventato obsoleto: prima identifichi l’account che lo sostituisce, trasferisci lo storico necessario e poi verifichi che non siano rimaste tracce su quello che andrai a spazzare via.

Per pochi account puoi procedere uno alla volta senza problemi, se lo preferisci e ti fa sentire più sicuro di quello che stai per fare. In una migrazione di dominio, però, il mapping CSV e lo script bulk rendono il processo più corretto, privo di sbavature (a meno di averle fatte durante la preparazione di script e file di corrispondenza), lo scopo è cercare di portare a zero (o per lo meno alla minima cifra possibile) gli errori manuali.

Con un minimo di pianificazione e la maggiore attenzione possibile (ed ecco perché gli script che ti ho proposto sopra prevedono di lavorare sempre in sola lettura a meno di essere lanciati con uno specifico parametro), le API v3 di SDP in Cloud diventano l’unico strumento efficace per questo tipo di operazioni amministrative che da interfaccia grafica non hai a disposizione nonostante tu si amministratore dell’istanza.

L’area commenti è a tua disposizione se ti sei trovato in una situazione simile, se hai bisogno di delucidazioni sullo script o di chiedere banalmente una mano, nei limiti del possibile e del fattibile da qui :-)

#KeepItSimple


Immagine di copertina Israel Andrade on Unsplash

Correzioni, suggerimenti? Lascia un commento nell'apposita area qui di seguito o contattami privatamente.
Ti è piaciuto l'articolo? Offrimi un caffè! ☕ :-)

Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Commenti
Oldest
Newest Most Voted