Azure DevOps e il cambio di tenant Entra ID: quando il remapping automatico non basta

| |

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


Immagine di copertina: Rui Silvestre 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