Oggi ti parlo di imprecazione esperienza di vita lavorativa vissuta. Sulla carta, cambiare il tenant Microsoft Entra ID collegato a un’organizzazione Azure DevOps sembra un’operazione lineare: prepari la nuova directory, fai lo switch, lasci che Microsoft rimappi gli utenti verso le nuove identità (dopo che tu gli avrai dato una sorta di griglia di corrispondenza tra ‘vecchio’ e ‘nuovo’) e via.
Nella pratica, almeno nel mio caso, il remapping automatico ha fatto solo metà del lavoro. L’organizzazione e il progetto sono sopravvissuti bene al cambio di directory, insieme a repository, board e storico. Alcune identità, però, sono rimaste appese ai vecchi account, e a quel punto mi è toccato rimboccarmi le maniche, prendere un due di picche su un potenziale ticket a pagamento verso il supporto di Microsoft (nonostante in documentazione venga consigliata quella come unica via per risolvere questo tipo di magagne), lucidare la PowerShell e mettere sul piede di guerra ChatGPT che mi ha seguito durante tutto l’iter per evitare di dimenticarmi qualche passaggio.
In questo post metto giù la procedura che ho seguito per arrivare a una situazione pulita:
- nuove identità presenti e corrette in Azure DevOps;
- nessun Work Item ancora assegnato ai vecchi account;
- membership dei nuovi utenti verificate;
- vecchi utenti rimossi dall’organizzazione;
- storico di revisioni e commenti preservato così com’è;
- repository, branch, policy e ACL Git controllati;
- nessun permesso Git esplicito ancora legato alle identità legacy.
L’obiettivo non era cancellare ogni traccia dei vecchi utenti (avrebbe voluto dire alterare l’audit trail, e no grazie), ma assicurarmi che nessuna identità legacy fosse ancora necessaria per far girare il progetto oggi. Gli esempi che segui sono ovviamente anonimizzati: ID, UPN, organizzazione e progetto vanno adattati al tuo ambiente.
Prima di partire sul serio, ci vuole il giusto disclaimer:
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.
Se vai avanti con la lettura vuol dire che vuoi farti seriamente del male, che sei un addetto ai lavori o che ti sei ritrovato nella mia stessa bega. In tal caso ti abbraccio, fratello. I feel you.
Lo scenario
Tenant sorgente
└── oldtenant.onmicrosoft.com
├── a.rossi-cutover@oldtenant.onmicrosoft.com
└── m.bianchi-cutover@oldtenant.onmicrosoft.com
Azure DevOps
└── MyOrganization
└── MyProject
Tenant destinazione
└── newtenant.onmicrosoft.com
├── andrea.rossi@example.com
└── marco.bianchi@example.com
Gli utenti del tenant sorgente non dovevano restare come guest: esistevano già identità native nel tenant di destinazione, ed erano quelle che volevo vedere usate da Azure DevOps.Il cambio di directory dell’organizzazione è filato liscio (come ti avevo già scritto poco sopra), il problema è saltato fuori dopo, controllando cosa fosse effettivamente successo al remapping delle identità.
Il remapping di Microsoft si è fermato a metà strada
Dopo lo switch, alcune identità risultavano collegate correttamente, altre erano rimaste in uno stato un po’ anomalo.
Una identità nuova e collegata bene a Entra ID si presentava così:
principalName : andrea.rossi@example.com origin : aad originId : <Entra Object ID> descriptor : aad....
Alcune vecchie identità, invece, così:
principalName : a.rossi-cutover@oldtenant.onmicrosoft.com origin : aad originId : descriptor : bnd....
L’assenza di originId e il descriptor bnd.* sono stati il campanello: lo switch della directory era riuscito, ma non potevo considerare il lavoro finito solo per quello. Ho anche provato ad aspettare qualche ora nell’inutile (con il senno di poi) speranza che Microsoft ci mettesse più tempo del previsto a rimettere le cose a posto (ho lasciato passare la notte dopo la migrazione), ma nulla da fare. Ho quindi gestito a mano le parti che restavano: nuove identità, assegnazioni correnti, membership e infine la rimozione dei vecchi utenti dall’organizzazione.
Preparare la sessione PowerShell
REST API di Azure DevOps e PowerShell 7. Inizia a prepararti le variabili che ti serviranno più volte, nei passaggi successivi.
$Organization = "MyOrganization" $Project = "MyProject"
Vai a crearti un PAT (Personal Access Token) sul DevOps, io ne ho tirato in piedi uno della durata di un giorno, così da evitare poi di dimenticarmelo lì attivo e potenzialmente “dannoso“.
Per farlo digerire e gestire dagli script PowerShell ho evitato di scriverlo in chiaro da qualche parte, l’ho fatto richiedere direttamente “live“:
$Pat = Read-Host "Azure DevOps PAT" -AsSecureString
$Ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Pat)
try {
$PlainPat = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($Ptr)
$Token = [Convert]::ToBase64String(
[Text.Encoding]::ASCII.GetBytes(":$PlainPat")
)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($Ptr)
}
$Headers = @{
Authorization = "Basic $Token"
}
Gli scope del PAT dipendono da cosa devi fare: per l’audit bastano quelli di lettura sulle aree coinvolte, per modifiche e rimozioni servono i relativi permessi di gestione, io ammetto di aver preso la scorciatoia e creato un PAT con FullAccess dopo aver inizialmente perso tempo con un PAT limitato.
Recuperare il progetto
Prima tappa: recupero l’ID del progetto, che mi serve in praticamente tutte le chiamate successive.
$ProjectUri = "https://dev.azure.com/$Organization/_apis/projects/${Project}?api-version=7.1"
$ProjectInfo = Invoke-RestMethod `
-Uri $ProjectUri `
-Headers $Headers `
-Method Get
$ProjectInfo |
Select-Object id,name,state |
Format-List
$ProjectId = $ProjectInfo.id
Nota PowerShell da tenere a mente: quando una variabile è seguita subito da ? in una URL, delimitala con ${...}, altrimenti PowerShell la interpreta come vuole lui, chiediti perché te lo sto scrivendo qui (sì, pur sapendolo ho fatto il classico errore da principiante al primo giro).
Inventario degli utenti dell’organizzazione
Punto di partenza: chi ha accesso oggi all’organizzazione, con licenza e nome visualizzato (il displayName) così da avere un riferimento da confrontare con quello che troverai più avanti.
$EntitlementsUri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements?api-version=7.1"
$Entitlements = Invoke-RestMethod `
-Uri $EntitlementsUri `
-Headers $Headers `
-Method Get
$OrgUsers = $Entitlements.items
$OrgUsers |
Select-Object `
id,
@{N="DisplayName";E={$_.user.displayName}},
@{N="PrincipalName";E={$_.user.principalName}},
@{N="License";E={$_.accessLevel.accountLicenseType}} |
Format-Table -AutoSize
Dettaglio che mi ha fatto perdere un po’ di tempo: l’ID dell’entitlement è id a livello principale, non user.id. Segnati bene chi è chi, ti tornerà utile più avanti.
$OrgUsers |
Select-Object `
id,
@{N="DisplayName";E={$_.user.displayName}},
@{N="PrincipalName";E={$_.user.principalName}},
@{N="License";E={$_.accessLevel.accountLicenseType}} |
Export-Csv `
-Path ".\AzureDevOps-Users-BeforeCleanup.csv" `
-NoTypeInformation `
-Encoding UTF8
Inventario Azure DevOps Graph
Stesso discorso, ma lato Graph: qui trovi origin e descriptor, cioè i due campi che ti dicono se un’identità è collegata bene a Entra ID oppure no.
$GraphUsersUri = "https://vssps.dev.azure.com/$Organization/_apis/graph/users?api-version=7.1-preview.1"
$GraphUsersResult = Invoke-RestMethod `
-Uri $GraphUsersUri `
-Headers $Headers `
-Method Get
$GraphUsers = $GraphUsersResult.value
$GraphUsers |
Select-Object `
displayName,
principalName,
origin,
originId,
descriptor |
Sort-Object displayName |
Format-Table -AutoSize
Dal risultato della tabella che ti comparirà a video, otterrai una matrice esplicita vecchio/nuovo utente:
Vecchia identità Nuova identità ------------------------------------------------- ------------------------------ a.rossi-cutover@oldtenant.onmicrosoft.com andrea.rossi@example.com m.bianchi-cutover@oldtenant.onmicrosoft.com marco.bianchi@example.com
Prima di rimuovere un account legacy (quindi appartenente al vecchio DevOps), devi sapere con certezza quale identità lo sostituisce. Sembra ovvio scritto così, ma è il punto dove è più facile fare confusione (e io mi sono fatto due giri con esportazione CSV giusto per non fare danni stupidi da distrazione).
Recuperare tutti i Work Item
Serve l’elenco completo degli ID: da qui in poi ogni controllo (Assigned To, revisioni, commenti) scandisce questa lista uno per uno.
$WiqlUri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/wiql?api-version=7.1"
$WiqlBody = @{
query = @"
SELECT [System.Id]
FROM WorkItems
WHERE [System.TeamProject] = '$Project'
ORDER BY [System.Id]
"@
} | ConvertTo-Json
$WiqlResult = Invoke-RestMethod `
-Uri $WiqlUri `
-Headers $Headers `
-Method Post `
-ContentType "application/json" `
-Body $WiqlBody
$WorkItemIds = @($WiqlResult.workItems.id)
Write-Host "Work items found: $($WorkItemIds.Count)"
E una funzione di lettura riutilizzabile per i passaggi successivi:
function Get-AdoWorkItem {
param(
[Parameter(Mandatory)]
[int]$Id
)
$Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}?`$expand=fields&api-version=7.1"
Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Get
}
Trovare gli Assigned To legacy
Scandisco ogni Work Item e tengo solo quelli ancora assegnati a uno dei vecchi UPN.
$LegacyUpns = @(
"a.rossi-cutover@oldtenant.onmicrosoft.com",
"m.bianchi-cutover@oldtenant.onmicrosoft.com"
)
$LegacyAssignments = foreach ($Id in $WorkItemIds) {
$WorkItem = Get-AdoWorkItem -Id $Id
$Assigned = $WorkItem.fields.'System.AssignedTo'
if ($null -ne $Assigned) {
$AssignedUpn = $Assigned.uniqueName
if ($AssignedUpn -in $LegacyUpns) {
[pscustomobject]@{
Id = $Id
Title = $WorkItem.fields.'System.Title'
AssignedTo = $Assigned.displayName
AssignedUpn = $AssignedUpn
}
}
}
}
$LegacyAssignments |
Group-Object AssignedUpn |
Select-Object Name,Count |
Sort-Object Count -Descending |
Format-Table -AutoSize
Rimappare gli Assigned To a mano
Con un array di UserMapping vado a dichiarare esplicitamente chi sostituisce chi, poi applico il mapping ai Work Item trovati nel passaggio precedente.
$UserMapping = @{
"a.rossi-cutover@oldtenant.onmicrosoft.com" = "andrea.rossi@example.com"
"m.bianchi-cutover@oldtenant.onmicrosoft.com" = "marco.bianchi@example.com"
}
Così:
function Set-AdoWorkItemAssignedTo {
param(
[Parameter(Mandatory)]
[int]$Id,
[Parameter(Mandatory)]
[string]$NewAssignedTo
)
$Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}?api-version=7.1"
$Patch = @(
@{
op = "replace"
path = "/fields/System.AssignedTo"
value = $NewAssignedTo
}
) | ConvertTo-Json
Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Patch `
-ContentType "application/json-patch+json" `
-Body $Patch
}
E applicazione del mapping:
$Successful = 0
$Skipped = 0
$Failed = 0
foreach ($Assignment in $LegacyAssignments) {
$OldUpn = $Assignment.AssignedUpn
if (-not $UserMapping.ContainsKey($OldUpn)) {
$Skipped++
continue
}
$NewUpn = $UserMapping[$OldUpn]
try {
Set-AdoWorkItemAssignedTo `
-Id $Assignment.Id `
-NewAssignedTo $NewUpn
$Successful++
}
catch {
Write-Warning "Failed Work Item $($Assignment.Id): $($_.Exception.Message)"
$Failed++
}
}
Write-Host "Successful: $Successful"
Write-Host "Skipped: $Skipped"
Write-Host "Failed: $Failed"
Occhio a testare il mapping su un singolo Work Item prima, verificando il risultato dalla UI, e solo dopo lanciarlo su tutto il resto.
Su una migrazione di identità non è il campo dove vuoi scoprire un errore di mapping dopo averlo già applicato a 300 elementi. Meglio un’operazione “ripetuta almeno un paio di volte” che testare il brivido sull’intero contenitore, rifatti un po’ ai consigli della nonna quando ti suggeriva di provare il nuovo detersivo su un lembo non visibile della maglia o della camicia 😁
Verificare gli Assigned To
Stesso controllo di prima, ma dopo il mapping: serve a confermare che non sia rimasto nulla assegnato ai vecchi account.
$OldCurrentAssignments = foreach ($Id in $WorkItemIds) {
$WorkItem = Get-AdoWorkItem -Id $Id
$Assigned = $WorkItem.fields.'System.AssignedTo'
if (
$null -ne $Assigned -and
$Assigned.uniqueName -in $LegacyUpns
) {
[pscustomobject]@{
Id = $Id
AssignedUpn = $Assigned.uniqueName
}
}
}
Write-Host "Old current assignments: $($OldCurrentAssignments.Count)"
Il risultato che vuoi vedere è:
Old current assignments: 0
Lo storico: cosa NON ho toccato
Durante l’audit sono saltati fuori riferimenti ai vecchi utenti anche in altri campi:
Microsoft.VSTS.Common.ActivatedBy Microsoft.VSTS.Common.ClosedBy System.AuthorizedAs System.CreatedBy System.ChangedBy
Qui la distinzione è tutto. System.AssignedTo è uno stato operativo corrente, il “chi lavora su questa cosa adesso”. CreatedBy, ChangedBy, ActivatedBy, ClosedBy e simili raccontano invece chi ha fatto davvero un’azione nel passato.
Riscriverli avrebbe voluto dire attribuire alle nuove identità operazioni compiute dai vecchi account, cioè falsificare la storia del progetto. Li ho lasciati esattamente dove stavano.
Audit delle revisioni
Stesso principio del punto precedente, ma applicato allo storico: qui non guardo lo stato attuale, guardo chi ha modificato cosa nel tempo.
$RevisionLegacyUsers = [System.Collections.Generic.List[object]]::new()
foreach ($Id in $WorkItemIds) {
$Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workitems/${Id}/revisions?api-version=7.1"
$Revisions = (Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Get).value
foreach ($Revision in $Revisions) {
$ChangedBy = $Revision.fields.'System.ChangedBy'
if (
$null -ne $ChangedBy -and
$ChangedBy.uniqueName -in $LegacyUpns
) {
$RevisionLegacyUsers.Add(
[pscustomobject]@{
WorkItemId = $Id
Revision = $Revision.rev
ChangedBy = $ChangedBy.uniqueName
}
)
}
}
}
$RevisionLegacyUsers |
Group-Object ChangedBy |
Select-Object Count,Name |
Sort-Object Count -Descending |
Format-Table -AutoSize
Trovare risultati qui non è un errore: è semplicemente lo storico che fa il suo mestiere.
Audit degli autori dei commenti
Stesso identico controllo, stavolta sugli autori dei commenti ai Work Item.
$LegacyCommentAuthors = [System.Collections.Generic.List[object]]::new()
foreach ($Id in $WorkItemIds) {
$Uri = "https://dev.azure.com/$Organization/${Project}/_apis/wit/workItems/${Id}/comments?api-version=7.1-preview.4"
try {
$Comments = (Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Get).comments
}
catch {
continue
}
foreach ($Comment in $Comments) {
$AuthorUpn = $Comment.createdBy.uniqueName
if ($AuthorUpn -in $LegacyUpns) {
$LegacyCommentAuthors.Add(
[pscustomobject]@{
WorkItemId = $Id
CommentId = $Comment.id
Author = $AuthorUpn
}
)
}
}
}
$LegacyCommentAuthors |
Group-Object Author |
Select-Object Count,Name |
Sort-Object Count -Descending |
Format-Table -AutoSize
Anche questi, lasciati dove stavano.
Controllare le membership
Prima di cancellare qualsiasi account, voglio sapere a quali gruppi appartiene ogni utente: elenco prima i gruppi dell’organizzazione, poi le membership vere e proprie.
$GroupsUri = "https://vssps.dev.azure.com/$Organization/_apis/graph/groups?api-version=7.1-preview.1"
$Groups = (Invoke-RestMethod `
-Uri $GroupsUri `
-Headers $Headers `
-Method Get).value
$Groups |
Select-Object displayName,principalName,descriptor |
Sort-Object displayName |
Format-Table -AutoSize
Funzione per leggere le membership:
function Get-AdoMembership {
param(
[Parameter(Mandatory)]
[string]$Descriptor
)
$EncodedDescriptor = [System.Uri]::EscapeDataString($Descriptor)
$Uri = "https://vssps.dev.azure.com/$Organization/_apis/graph/memberships/${EncodedDescriptor}?direction=up&depth=1&api-version=7.1-preview.1"
(Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Get).value
}
E il report vero e proprio:
foreach ($User in $GraphUsers) {
$Memberships = Get-AdoMembership -Descriptor $User.descriptor
foreach ($Membership in $Memberships) {
$Group = $Groups |
Where-Object descriptor -eq $Membership.containerDescriptor |
Select-Object -First 1
[pscustomobject]@{
User = $User.principalName
Group = $Group.principalName
}
}
}
Questo controllo va fatto prima di eliminare gli account precedenti, non dopo. Azure DevOps usa membership transitive: un utente può avere permessi ricevuti dal team o dai gruppi padre, non solo da assegnazioni dirette, e se non lo controlli prima rischi di scoprirlo nel modo sbagliato.
Rimuovere gli utenti legacy
A questo punto isolo gli utenti legacy ancora presenti nell’organizzazione, prima di passare alla cancellazione vera e propria.
$LegacyOrgUsers = $OrgUsers |
Where-Object {
$_.user.principalName -in $LegacyUpns
}
$LegacyOrgUsers |
Select-Object `
id,
@{N="DisplayName";E={$_.user.displayName}},
@{N="PrincipalName";E={$_.user.principalName}} |
Format-Table -AutoSize
E la rimozione vera:
$Removed = 0
$Failed = 0
foreach ($User in $LegacyOrgUsers) {
$Uri = "https://vsaex.dev.azure.com/$Organization/_apis/userentitlements/$($User.id)?api-version=7.1"
try {
Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Delete
Write-Host "Removed: $($User.user.principalName)"
$Removed++
}
catch {
Write-Warning "Failed: $($User.user.principalName)"
Write-Warning $_.Exception.Message
$Failed++
}
}
Write-Host ""
Write-Host "Removed: $Removed"
Write-Host "Failed: $Failed"
Anche qui: l’identificativo giusto è $User.id, non $User.user.id. Lo stesso dettaglio dell’inventario iniziale, e lo stesso punto dove è facile inciampare due volte.
Verifica finale degli utenti
Richiamo di nuovo l’inventario degli utenti per essere sicuro che i vecchi account non ci siano più.
$Entitlements = Invoke-RestMethod `
-Uri $EntitlementsUri `
-Headers $Headers `
-Method Get
$OrgUsers = $Entitlements.items
$LegacyStillPresent = $OrgUsers |
Where-Object {
$_.user.principalName -in $LegacyUpns
}
Write-Host "Users remaining: $($OrgUsers.Count)"
Write-Host "Legacy users still present: $($LegacyStillPresent.Count)"
Anche qui, il risultato voluto è secco:
Legacy users still present: 0
Esportazione finale, per avere una fotografia dello stato pulito:
$OrgUsers |
Select-Object `
@{N="DisplayName";E={$_.user.displayName}},
@{N="PrincipalName";E={$_.user.principalName}},
@{N="License";E={$_.accessLevel.accountLicenseType}} |
Export-Csv `
-Path ".\AzureDevOps-FinalUsers.csv" `
-NoTypeInformation `
-Encoding UTF8
Perché le vecchie identità possono restare in Graph
Dopo la rimozione dagli entitlement, alcune identità legacy possono continuare a comparire nelle risposte di Azure DevOps Graph. Da solo, questo non significa che abbiano ancora accesso: nel mio caso erano ancora referenziate da revisioni, commenti e campi storici come ChangedBy, CreatedBy e ClosedBy, esattamente quelli che avevo deciso di preservare.
Per questo ho usato gli User Entitlements per stabilire chi avesse davvero accesso corrente, mentre Graph è rimasto uno strumento per analizzare identità e riferimenti storici, non per decidere chi può ancora entrare.
Audit dei repository
Chiuso il fronte utenti, passo a un altro fronte: i repository Git dell’organizzazione.
$ReposUri = "https://dev.azure.com/$Organization/${Project}/_apis/git/repositories?api-version=7.1"
$Repos = (Invoke-RestMethod `
-Uri $ReposUri `
-Headers $Headers `
-Method Get).value
$Repos |
Select-Object id,name,defaultBranch,isDisabled |
Sort-Object name |
Format-Table -AutoSize
Write-Host "Repositories found: $($Repos.Count)"
Enumerare i branch
Per ogni repository elenco i branch: mi serve più avanti, quando incrocio i token delle ACL con i repository ancora esistenti.
$BranchReport = foreach ($Repo in $Repos) {
$RefsUri = "https://dev.azure.com/$Organization/${Project}/_apis/git/repositories/$($Repo.id)/refs?filter=heads/&api-version=7.1"
$Refs = (Invoke-RestMethod `
-Uri $RefsUri `
-Headers $Headers `
-Method Get).value
foreach ($Ref in $Refs) {
$BranchName = ($Ref.name -replace '^refs/heads/', '')
[pscustomobject]@{
Repository = $Repo.name
Branch = $BranchName
ObjectId = $Ref.objectId
}
}
}
$BranchReport |
Sort-Object Repository,Branch |
Export-Csv `
-Path ".\AzureDevOps-Branches.csv" `
-NoTypeInformation `
-Encoding UTF8
Verificare le branch policy
Controllo anche le policy sui branch, per essere sicuro che nessuna faccia ancora riferimento agli account legacy.
$PoliciesUri = "https://dev.azure.com/$Organization/${Project}/_apis/policy/configurations?api-version=7.1"
$Policies = (Invoke-RestMethod `
-Uri $PoliciesUri `
-Headers $Headers `
-Method Get).value
Write-Host "Branch policies found: $($Policies.Count)"
Nel mio scenario non c’erano branch policy da migrare o correggere, ma il controllo va fatto comunque: è un minuto di script contro il rischio di scoprire dopo che una policy puntava a un account che non esiste più.
Audit delle ACL Git
Il namespace di sicurezza Git è fisso:
2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87
$GitNamespaceId = "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87"
$ProjectGitToken = "repoV2/$ProjectId"
$EncodedProjectGitToken = [System.Uri]::EscapeDataString($ProjectGitToken)
$AclUri = "https://dev.azure.com/$Organization/_apis/accesscontrollists/${GitNamespaceId}?token=$EncodedProjectGitToken&recurse=true&includeExtendedInfo=true&api-version=7.1"
$GitAclResult = Invoke-RestMethod `
-Uri $AclUri `
-Headers $Headers `
-Method Get
Estrazione delle ACE:
$AclDescriptors = foreach ($Acl in $GitAclResult.value) {
foreach ($Property in $Acl.acesDictionary.PSObject.Properties) {
[pscustomobject]@{
Token = $Acl.token
Descriptor = $Property.Name
Allow = $Property.Value.allow
Deny = $Property.Value.deny
}
}
}
$AclDescriptors |
Format-Table Token,Descriptor,Allow,Deny -AutoSize
I descriptor delle ACL non sono quelli di Graph
Nelle ACL Git ho trovato descriptor di questo tipo:
Microsoft.TeamFoundation.Identity;S-1-9-...
mentre Graph usa valori come:
aad.... bnd.... vssgp....
Non sono confrontabili direttamente, e la prima volta che li vedi affiancati viene da pensare di aver sbagliato qualcosa. Prima estrai i descriptor relativi alle identity:
$IdentityDescriptors = $AclDescriptors |
Where-Object {
$_.Descriptor -like "Microsoft.TeamFoundation.Identity;*"
} |
Select-Object -ExpandProperty Descriptor -Unique
Poi li risolvi tramite l’API Identities:
$ResolvedIdentities = [System.Collections.Generic.List[object]]::new()
$BatchSize = 20
for (
$Offset = 0;
$Offset -lt $IdentityDescriptors.Count;
$Offset += $BatchSize
) {
$LastIndex = [Math]::Min(
$Offset + $BatchSize - 1,
$IdentityDescriptors.Count - 1
)
$Batch = @(
$IdentityDescriptors[$Offset..$LastIndex]
)
$DescriptorList = $Batch -join ","
$EncodedDescriptors = [System.Uri]::EscapeDataString(
$DescriptorList
)
$Uri = "https://vssps.dev.azure.com/$Organization/_apis/identities?descriptors=$EncodedDescriptors&queryMembership=None&api-version=7.1"
$Result = Invoke-RestMethod `
-Uri $Uri `
-Headers $Headers `
-Method Get
foreach ($Identity in $Result.value) {
$ResolvedIdentities.Add(
[pscustomobject]@{
Descriptor = $Identity.descriptor
ProviderDisplayName = $Identity.providerDisplayName
SubjectDescriptor = $Identity.subjectDescriptor
IsActive = $Identity.isActive
IsContainer = $Identity.isContainer
Id = $Identity.id
}
)
}
}
$ResolvedIdentities |
Sort-Object ProviderDisplayName |
Format-Table `
ProviderDisplayName,
IsActive,
IsContainer,
SubjectDescriptor `
-AutoSize
Nel mio caso le ACL Git facevano riferimento solo a gruppi Azure DevOps, tra cui:
[Organization]\Project Collection Administrators [Organization]\Project Collection Build Service Accounts [Organization]\Project Collection Service Accounts [Project]\Build Administrators [Project]\Contributors [Project]\Project Administrators [Project]\Readers
Nessuna ACE Git esplicitamente intestata ai vecchi utenti. Bene così.
ACL legate a repository che non esistono più
Ultimo giro: confrontare i token ACL con i repository correnti, per beccare eventuali residui orfani.
$CurrentRepoIds = @(
$Repos.id |
ForEach-Object {
$_.ToString().ToLowerInvariant()
}
)
$RepositoryAclReport = foreach ($Acl in $GitAclResult.value) {
if ($Acl.token -match '^repoV2/[^/]+/([^/]+)$') {
$RepoId = $Matches[1].ToLowerInvariant()
$Repo = $Repos |
Where-Object {
$_.id.ToString().ToLowerInvariant() -eq $RepoId
} |
Select-Object -First 1
[pscustomobject]@{
RepositoryId = $RepoId
Repository = $Repo.name
Exists = [bool]$Repo
Token = $Acl.token
}
}
}
$RepositoryAclReport |
Where-Object Exists -eq $false |
Format-Table RepositoryId,Repository,Exists,Token -AutoSize
Nel mio ambiente è rimasto un token ACL relativo a un repository non più esistente. Non l’ho considerato un blocco per la migrazione delle identità, visto che le sue ACE non contenevano riferimenti agli utenti legacy: resta housekeeping da fare a parte, non un problema di identità.
Checklist finale
A fine giro, ecco il riepilogo secco di tutto quello controllato:
[OK] Directory Azure DevOps collegata al nuovo tenant [OK] Nuove identità presenti [OK] Mapping vecchio -> nuovo verificato [OK] AssignedTo legacy = 0 [OK] Membership delle nuove identità verificate [OK] Utenti legacy attivi nell'organizzazione = 0 [OK] Revisioni storiche preservate [OK] Autori storici dei commenti preservati [OK] Repository correnti verificati [OK] Branch verificati [OK] Branch policy verificate [OK] ACL Git verificate [OK] ACE Git esplicite associate a utenti legacy = 0
Solo a questo punto ho considerato davvero conclusa la migrazione delle identità.
In conclusione
La cosa più importante che mi porto a casa da questa storia è che cambiare la directory di Azure DevOps e rimappare gli utenti non sono la stessa cosa, anche se Microsoft te la vende un po’ come se lo fossero. Nel mio caso lo switch del tenant è riuscito, ma il remapping automatico ha lasciato alcune identità a metà strada. E cercare di cancellare ogni traccia delle vecchie identità sarebbe stato un errore uguale e contrario: la distinzione che conta davvero è tra stato operativo corrente e storico del progetto.
Gli utenti legacy non devono più avere accesso, membership o assegnazioni attive. Ma devono poter continuare a comparire come autori di una modifica fatta in passato, di un commento, della chiusura di un Work Item: quella è storia, non uno stato da “correggere“.
Il risultato giusto non è “Azure DevOps non conosce più le vecchie identità“, è piuttosto: le vecchie identità non hanno più alcun ruolo operativo, ma Azure DevOps conserva correttamente ciò che hanno fatto. Ed è questa, nel mio caso, la verifica che mi ha permesso di dire davvero conclusa la migrazione.
L’area commenti è a tua disposizione se ti sei trovato in uno scenario simile o se hai un pezzo di script da confrontare. Ma anche per riprenderti dalla depressione che ti è venuta se hai letto fino a qui: il caffè lo offro io. Alcuni dei passaggi qui sopra li ho dovuti studiare (e in alcuni casi farmeli pure spiegare meglio dal povero agente AI di turno) perché mi erano totalmente sfuggiti, non c’è peccato nell’ammettere dei limiti, era la prima volta che mi ritrovavo in una situazione simile.
#KeepItSimple