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:
- identificare e mappare correttamente vecchio e nuovo richiedente;
- verificare i ticket legati al vecchio account;
- spostarli / riassegnarli al nuovo;
- controllare che sul vecchio non rimanga più nulla;
- eliminare l’account non più necessario;
- 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