Site icon Gioxx.org

Graph: modificare facilmente gruppi Entra via PowerShell

PowerShell: report licenze Microsoft 365 via Microsoft Graph 1

Solito blocco appunti che potenzialmente mi tornerà ancora utile in futuro, magari però chiarisce le idee e torna comodo anche a te che ci sei atterrato sopra.

Scenario: avevo bisogno di modificare velocemente nomi e descrizioni di una decina circa di gruppi Entra (quindi solo Cloud), e subito dopo crearne di nuovi mantenendo la stessa naming convention ma andando a ritoccare l’ultima parte. Leggi l’articolo, è più facile che spiegarlo qui nell’introduzione!

PowerShell 7, moduli di Microsoft Graph già installati, è un passaggio obbligato.
Connettiti a Microsoft Graph e, se non sai come farlo, sbircia uno dei miei precedenti articoli.

Alla ricerca dei gruppi

La base: tutti i gruppi che mi interessava rinominare hanno nome che comincia per “GitLab”. Per questo motivo, ho potuto lanciare facilmente una ricerca:

Get-MgGroup -Filter "startsWith(displayName,'GitLab')" -All

Questo produce una lista ordinata di gruppi con nome che comincia per GitLab, restituendo anche altre informazioni utili (Id del gruppo, descrizione, ecc.). Ho poi dovuto escludere un singolo gruppo dalla ricerca, questo non sarebbe stato soggetto alle successive modifiche. Il gruppo da escludere è GitLab-Full, perciò:

$groups = Get-MgGroup -Filter "startswith(displayName,'GitLab')" -ConsistencyLevel eventual -CountVariable groupCount -All
$groups | Where-Object { $_.DisplayName -ne 'GitLab-Full' } | Select-Object Id, DisplayName | Sort-Object DisplayName

Basato totalmente sulla prima query che ti ho mostrato, ma in questo caso ho tenuto conto di qualche parametro aggiuntivo e – infine – escluso quello che nel DisplayName compariva proprio come GitLab-Full.

Rinominare i gruppi

Avevo bisogno di accodare un semplice _DEV al nome dei gruppi trovati. Per questo motivo, ho potuto sfruttare il più classico dei cicli ForEach-Object, lanciando dapprima una Preview di quello che sarebbe poi accaduto:

$groups = Get-MgGroup -Filter "startswith(displayName,'GitLab')" -ConsistencyLevel eventual -All | Where-Object { $_.DisplayName -ne 'GitLab-Full' }
foreach ($grp in $groups) {
    $newName = $grp.DisplayName + "_DEV"
    Update-MgGroup -GroupId $grp.Id -DisplayName $newName
    Write-Host "Renamed $($grp.DisplayName) -> $newName"
}

Questo mi ha permesso di vedere cosa sarebbe successo se avessi lanciato realmente il ciclo per rinominare i gruppi. Considerato che era tutto assolutamente come previsto, ho leggermente modificato la query per intervenire sul serio:

$groups = Get-MgGroup -Filter "startswith(displayName,'GitLab')" -ConsistencyLevel eventual -All | Where-Object { $_.DisplayName -ne 'GitLab-Full' }
foreach ($grp in $groups) {
    $newName = $grp.DisplayName + "_DEV"
    Update-MgGroup -GroupId $grp.Id -DisplayName $newName
    Write-Host "Renamed $($grp.DisplayName) -> $newName"
}

Posso anche tornare indietro?

Beh, sì.
Se per indietro intendi “eliminare quel _DEV” in coda, allora lo puoi fare semplicemente lanciando una query che andrà a rimuovere quell’ultima parte dal nome di ciascun gruppo:

$groups = Get-MgGroup -Filter "endswith(displayName,'_DEV')" -ConsistencyLevel eventual -All
foreach ($grp in $groups) {
    $originalName = $grp.DisplayName -replace '_DEV$',''
    Update-MgGroup -GroupId $grp.Id -DisplayName $originalName
    Write-Host "Renamed back $($grp.DisplayName) -> $originalName"
}

Facci caso: la ricerca, stavolta, terrà conto del fatto che andrà a trovare gruppi con nome che termina in _DEV, processandoli e rinominandoli per eliminare quel suffisso. Per questo motivo, se hai altri gruppi che terminano per _DEV e non c’entrano nulla con quelli che devi ritoccare, allora NON lanciare la query che ti ho proposto qui sopra.
Dovrai prima adattarla al tuo scenario per fare una ricerca più specifica, magari combinando il “comincia con” (startswith) e “finisce con” (endswith).

E la descrizione?

Giusta osservazione.
Non cambia poi molto dalla query che ti ha permesso di rinominare i gruppi. Stavolta, però, andremo ad accodare un testo particolare (in questo caso “(Ruolo: Developer)“) alla descrizione già esistente:

$groups = Get-MgGroup -Filter "startswith(displayName,'GitLab')" -ConsistencyLevel eventual -All | Where-Object { $_.DisplayName -ne 'GitLab-Full' }
foreach ($grp in $groups) {
    $newDesc = ($grp.Description + " (Ruolo: Developer)").Trim()
    Update-MgGroup -GroupId $grp.Id -Description $newDesc
    Write-Host "Updated $($grp.DisplayName) Description -> $newDesc"
}

Posso tornare indietro anche stavolta?

Un eventuale rollback di questa modifica si affronta più o meno alla stessa maniera di come hai affrontato quello del rename:

$groups = Get-MgGroup -ConsistencyLevel eventual -All | Where-Object { $_.Description -match '\(Ruolo: Developer\)$' }
foreach ($grp in $groups) {
    $originalDesc = $grp.Description -replace '\s*\(Ruolo: Developer\)$',''
    Update-MgGroup -GroupId $grp.Id -Description $originalDesc
    Write-Host "Restored description for $($grp.DisplayName): $originalDesc"
}

Forse inutile dirlo ma è bene sottolinearlo nuovamente: la ricerca, stavolta, terrà conto del fatto che andrà a trovare gruppi con descrizione che termina in (Ruolo: Developer). Se hai altri gruppi con descrizione che termina alla stessa maniera e che non c’entrano nulla con quelli che devi ritoccare, allora NON lanciare la query che ti ho proposto qui sopra.
Dovrai prima adattarla al tuo scenario per fare una ricerca più specifica, magari combinando il nome del gruppo tramite un “comincia con” (startswith) e il filtro RegEx che ti permette di individuare chi contiene quel testo a fine descrizione.

Nome del gruppo e descrizione in un solo colpo

Tecnicamente si sarebbe potuto fare tutto insieme sin dall’inizio, sia rinominare il gruppo che modificargli la descrizione. Basta ritoccare un pelo la query che ti ho già proposto un po’ più sopra, quella di rinomina, dicendole di andare anche ad accodare un testo particolare (in questo caso “(Ruolo: Developer)“) alla descrizione già esistente:

$groups = Get-MgGroup -Filter "startswith(displayName,'GitLab')" -ConsistencyLevel eventual -All | Where-Object { $_.DisplayName -ne 'GitLab-Full' }
foreach ($grp in $groups) {
    $newName = $grp.DisplayName + "_DEV"
    $newDesc = ($grp.Description + " (Ruolo: Developer)").Trim()
    Update-MgGroup -GroupId $grp.Id -DisplayName $newName -Description $newDesc
    Write-Host "Updated $($grp.DisplayName) -> $newName | Description -> $newDesc"
}

Clonazione dei gruppi

Lavoro più seccante: dovevo clonare i gruppi esistenti (sempre tenendo escluso il full) dando origine a quelli _MAINT (al posto di _DEV) adattando anche la descrizione e, già che c’ero, clonando proprietari dei gruppi e membri. Ho dato in pasto tutto a ChatGPT 5 e, dopo aver aggiustato un attimo il tiro sugli errori commessi, eccoti lo script completo che si occupa del lavoro sporco, tenendo conto che si tratta di clonare dei gruppi di sicurezza su Entra:

<#
.SYNOPSIS
Clone SECURITY groups to new *_MAINT Security groups and set description to include "(Ruolo: Maintainer)" once.
Copies owners and members. Dry-run by default.

.PREREQUISITES
Connect-MgGraph -Scopes "Group.ReadWrite.All","Directory.ReadWrite.All"

.PARAMETERS
- Prefix:       Source groups must start with this displayName prefix (default: "GitLab")
- ExcludeNames: Exact source displayNames to skip
- DryRun:       If true (default), only preview without creating groups
#>

param(
    [string]   $Prefix       = "GitLab",
    [string[]] $ExcludeNames = @("GitLab-Full"),
    [switch]   $DryRun       = $true
)

function Escape-ODataLiteral {
    param([Parameter(Mandatory)][string]$Value)
    # Escape single quotes for OData string literals
    return $Value.Replace("'", "''")
}

function Normalize-Ascii {
    param([Parameter(Mandatory)][string]$Text)
    # Strip diacritics -> ASCII
    $formD = $Text.Normalize([Text.NormalizationForm]::FormD)
    $sb = New-Object System.Text.StringBuilder
    foreach ($ch in $formD.ToCharArray()) {
        if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($ch) -ne [Globalization.UnicodeCategory]::NonSpacingMark) {
            [void]$sb.Append($ch)
        }
    }
    return $sb.ToString()
}

function Sanitize-MailNickname {
    param([Parameter(Mandatory)][string]$Name)
    # Lowercase; keep only a-z, 0-9; max length 64 (Graph limit)
    $ascii = Normalize-Ascii $Name
    $nick = ($ascii.ToLower() -replace '[^a-z0-9]', '')
    if ([string]::IsNullOrWhiteSpace($nick)) { $nick = 'group' }
    if ($nick.Length -gt 64) { $nick = $nick.Substring(0,64) }
    return $nick
}

function Get-UniqueMailNickname {
    param([Parameter(Mandatory)][string]$BaseNickname)
    $base = (Sanitize-MailNickname $BaseNickname)
    $candidate = $base
    $i = 1
    while ($true) {
        $exists = Get-MgGroup -Filter "mailNickname eq '$candidate'" -ConsistencyLevel eventual -All
        if (-not $exists) { return $candidate }
        $i++
        $suffix = $i.ToString()
        $candidate = $base
        if ($candidate.Length -gt (64 - $suffix.Length)) {
            $candidate = $candidate.Substring(0, 64 - $suffix.Length)
        }
        $candidate = $candidate + $suffix
    }
}

Write-Host "Fetching SECURITY source groups starting with '$Prefix'..." -ForegroundColor Cyan

# Server-side prefix filter, then keep only Security groups locally
$sourceGroups = Get-MgGroup `
  -Filter "startswith(displayName,'$Prefix')" `
  -ConsistencyLevel eventual `
  -All `
  -Property "id,displayName,description,securityEnabled,mailEnabled" |
  Where-Object {
      $_.SecurityEnabled -eq $true -and
      ($_.MailEnabled -ne $true) -and
      ($ExcludeNames -notcontains $_.DisplayName)
  }

if (-not $sourceGroups) {
    Write-Warning "No SECURITY groups found with prefix '$Prefix' (after exclusions). Nothing to do."
    return
}

Write-Host ("Found {0} SECURITY source groups" -f $sourceGroups.Count)

foreach ($g in $sourceGroups) {

    # --- Build target DisplayName (normalize suffix first) -----------------
    $srcName  = $g.DisplayName
    $baseName = $srcName -replace '_MAINT$','' -replace '_DEV$',''  # remove any trailing _MAINT or _DEV
    $newDisplayName = "{0}_MAINT" -f $baseName

    # --- Build target Description (idempotent role tag) --------------------
    $roleTag = " (Ruolo: Maintainer)"
    $cleanDesc = "$($g.Description)"
    # Remove any existing role tags to avoid duplication, then append once
    $cleanDesc = $cleanDesc -replace '\s*\(Ruolo:\s*Developer\)\s*',''
    $cleanDesc = $cleanDesc -replace '\s*\(Ruolo:\s*Maintainer\)\s*',''
    $newDescription = ($cleanDesc.Trim() + $roleTag).Trim()

    # Idempotency: skip if a target with same DisplayName already exists
    $escapedTarget = Escape-ODataLiteral $newDisplayName
    $existing = Get-MgGroup -Filter "displayName eq '$escapedTarget'" -ConsistencyLevel eventual -All
    if ($existing) {
        Write-Warning "Target already exists: '$newDisplayName' (skipping)"
        continue
    }

    # Always provide a MailNickname (some SDK/API paths require it even for Security groups)
    $mailNickBase = "${baseName}_maint"   # use normalized baseName here
    $mailNickname = Get-UniqueMailNickname -BaseNickname $mailNickBase

    Write-Host "------------------------------------------------------------"
    Write-Host "SOURCE : $srcName  (Security)"
    Write-Host "TARGET : $newDisplayName"
    Write-Host "DESC   : $newDescription"
    Write-Host "ALIAS  : $mailNickname"

    if ($DryRun) {
        Write-Host "[Dry-Run] Would create Security group and copy owners/members." -ForegroundColor Yellow
        continue
    }

    # --- Create Security group --------------------------------------------
    try {
        $createParams = @{
            DisplayName     = $newDisplayName
            Description     = $newDescription
            SecurityEnabled = $true
            MailEnabled     = $false
            MailNickname    = $mailNickname
        }
        $newGroup = New-MgGroup @createParams

        if (-not ($newGroup -and $newGroup.Id)) {
            throw "Creation returned no Id."
        }

        Write-Host "Created: $newDisplayName  (Id: $($newGroup.Id))" -ForegroundColor Green
    } catch {
        Write-Error "Creation failed for '$newDisplayName': $($_.Exception.Message)"
        continue
    }

    # --- Copy owners -------------------------------------------------------
    try {
        $owners = Get-MgGroupOwner -GroupId $g.Id -All
        if ($owners) {
            $added = 0
            foreach ($o in $owners) {
                try {
                    New-MgGroupOwnerByRef -GroupId $newGroup.Id -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/$($o.Id)" | Out-Null
                    $added++
                } catch {
                    Write-Warning "Owner add failed ($($o.Id)) -> $($newDisplayName): $($_.Exception.Message)"
                }
            }
            Write-Host ("Owners copied: {0}" -f $added)
        } else {
            Write-Host "No owners to copy."
        }
    } catch {
        Write-Warning "Owners step failed for '$newDisplayName': $($_.Exception.Message)"
    }

    # --- Copy members ------------------------------------------------------
    try {
        $members = Get-MgGroupMember -GroupId $g.Id -All
        if ($members) {
            $added = 0
            foreach ($m in $members) {
                try {
                    New-MgGroupMemberByRef -GroupId $newGroup.Id -OdataId "https://graph.microsoft.com/v1.0/directoryObjects/$($m.Id)" | Out-Null
                    $added++
                } catch {
                    Write-Warning "Member add failed ($($m.Id)) -> $($newDisplayName): $($_.Exception.Message)"
                }
            }
            Write-Host ("Members copied: {0}" -f $added)
        } else {
            Write-Host "No members to copy."
        }
    } catch {
        Write-Warning "Members step failed for '$newDisplayName': $($_.Exception.Message)"
    }
}

Write-Host "Done."

Salva lo script sul tuo PC, modificalo come reputi opportuno (perché al suo interno io ho comunque usato i riferimenti ai miei gruppi GitLab) e poi usalo!
Considera che, per comportamento predefinito, partirà in DryRun, perciò ti mostrerà a video cosa farebbe se venisse lanciato in scrittura. Se vuoi fargli fare sul serio il lavoro, allora ricorda di lanciarlo da riga di comando con parametro -DryRun:$false.

In caso di errori, dubbi, boiate riportate in articolo sai già cosa fare, l’area commenti è a tua totale disposizione :-)

#KeepItSimple

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

Exit mobile version