Compartir a través de


Automatización de la auditoría y administración de seguridad

Azure DevOps Services | Azure DevOps Server | Azure DevOps Server 2022

Este artículo contiene scripts de PowerShell que auditan la seguridad en toda la organización de Azure DevOps y proporcionan herramientas administrativas para tareas comunes de seguridad. Use estos scripts para auditar el acceso de los usuarios, revisar las conexiones de servicio, examinar las dependencias de vulnerabilidades y realizar tareas administrativas específicas, como cambiar la visibilidad del proyecto y administrar las pertenencias a grupos.

Estos scripts le ayudan a mantener la visibilidad de su posición de seguridad, identificar posibles riesgos y simplificar la selección de tareas de seguridad administrativas a través de la automatización.

Aviso de declinación de responsabilidades

Estos scripts son ejemplos proporcionados para mayor comodidad. Revíselas, pruébelas en un entorno no de producción, y adáptelas a las políticas de su organización antes de ejecutarlas en producción. Valide la corrección y la seguridad del entorno.

Prerrequisitos

Categoría Description
Azure DevOps - Una organización y un proyecto de Azure DevOps. cree una de forma gratuita.
- Permisos: Propietario de la organización o Administrador de colecciones de proyectos para scripts de nivel de organización; Administrador de proyectos para operaciones de nivel de proyecto
Autenticación Tokens de ID de Microsoft Entra. Los scripts adquieren tokens mediante la CLI de Azure: az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798. Asegúrese de que ha iniciado sesión con az login
Herramientas - Se requiere la CLI de Azure (az) y PowerShell (pwsh)
- Scripts escritos para PowerShell 7+; adaptar para Windows PowerShell
- Módulo de Azure PowerShell (Install-Module -Name Az) para algunos scripts
Safety - Primero, prueba en una organización no productiva
- Reasignar o cerrar las tareas activas antes de quitar usuarios
- Revisar los requisitos previos de visibilidad del proyecto antes de que los proyectos sean públicos
- Mantener la traza de auditoría de los cambios automatizados
Security - Almacenamiento de credenciales y tokens de forma segura (Key Vault o variables secretas de canalización)
- Revocar tokens o principales de servicio después de las operaciones masivas si se necesita rotación de credenciales

Comienza

¿Qué script debo usar?

Su objetivo Script recomendado Frecuencia de muestra
Búsqueda de riesgos de seguridad Auditoría de acceso de usuario Monthly
Limpieza de permisos Quitar miembros del grupo Según sea necesario
Preparación para la salida del usuario Reasignar elementos de trabajoquitar miembros del grupo Según sea necesario
Auditoría de conexiones Auditoría de la conexión del servicio Trimestral
Comprobación de dependencias Analizador de dependencias Weekly

Auditar la seguridad y el acceso en toda la organización

Auditar el acceso y los permisos de usuario

Este script audita e informa sobre los permisos de usuario en toda la organización:

# User Access Audit Script
param(
    [Parameter(Mandatory=$true)]
    [string]$organization,
    [string]$outputPath = "C:\AzureDevOps\UserAccessAudit.csv",
    [string]$apiVersion = "7.1-preview"
)

# Authenticate and setup
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
    Connect-AzAccount
}

$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$token = (Get-AzAccessToken -ResourceUrl $resourceUrl).Token
$headers = @{
    Authorization = "Bearer $token"
    "Content-Type" = "application/json"
}

Write-Host "Starting user access audit for organization: $organization" -ForegroundColor Cyan

$auditResults = @()
$riskFindings = @()

# Get all users in the organization
$usersUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/users?api-version=$apiVersion"
$users = Invoke-RestMethod -Uri $usersUrl -Method Get -Headers $headers

# Get all groups and their members
$groupsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups?api-version=$apiVersion"
$groups = Invoke-RestMethod -Uri $groupsUrl -Method Get -Headers $headers

Write-Host "Found $($users.count) users and $($groups.count) groups" -ForegroundColor Yellow

foreach ($user in $users.value) {
    try {
        # Get user's group memberships
        $membershipsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$($user.descriptor)?direction=up&api-version=$apiVersion"
        $memberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
        
        # Analyze access level and permissions
        $isAdmin = $false
        $adminGroups = @()
        $lastAccessDate = "Unknown"
        
        foreach ($membership in $memberships.value) {
            $groupName = ($groups.value | Where-Object { $_.descriptor -eq $membership.containerDescriptor }).displayName
            
            if ($groupName -match "Administrator|Admin|Collection|Project Collection") {
                $isAdmin = $true
                $adminGroups += $groupName
            }
        }
        
        # Check for inactive users with admin access
        if ($isAdmin -and $user.lastAccessedDate) {
            $lastAccess = [DateTime]$user.lastAccessedDate
            if ($lastAccess -lt (Get-Date).AddDays(-90)) {
                $riskFindings += [PSCustomObject]@{
                    Type = "Inactive Admin User"
                    User = $user.displayName
                    Email = $user.mailAddress
                    LastAccess = $lastAccess
                    AdminGroups = ($adminGroups -join ", ")
                    Risk = "High"
                }
            }
        }
        
        # Check for external users with high privileges
        if ($user.mailAddress -notlike "*@yourdomain.com" -and $isAdmin) {
            $riskFindings += [PSCustomObject]@{
                Type = "External Admin User"
                User = $user.displayName
                Email = $user.mailAddress
                LastAccess = $user.lastAccessedDate
                AdminGroups = ($adminGroups -join ", ")
                Risk = "High"
            }
        }
        
        $auditResults += [PSCustomObject]@{
            DisplayName = $user.displayName
            Email = $user.mailAddress
            Domain = if ($user.mailAddress) { $user.mailAddress.Split('@')[1] } else { "Unknown" }
            IsAdmin = $isAdmin
            AdminGroups = ($adminGroups -join ", ")
            LastAccess = $user.lastAccessedDate
            UserType = if ($user.mailAddress -like "*@yourdomain.com") { "Internal" } else { "External" }
            AccountEnabled = if ($user.metaType -eq "member") { "Active" } else { "Inactive" }
        }
        
        Write-Progress -Activity "Auditing users" -Status "Processing $($user.displayName)" -PercentComplete (($auditResults.Count / $users.count) * 100)
    }
    catch {
        Write-Warning "Failed to process user $($user.displayName): $($_.Exception.Message)"
    }
}

# Generate reports
Write-Host "Generating audit reports..." -ForegroundColor Yellow

# Export full audit results
$auditResults | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Full audit exported to: $outputPath" -ForegroundColor Green

# Display risk summary
if ($riskFindings.Count -gt 0) {
    Write-Host "Security risks identified:" -ForegroundColor Red
    $riskFindings | Format-Table -AutoSize
    
    $riskPath = $outputPath.Replace('.csv', '_SecurityRisks.csv')
    $riskFindings | Export-Csv -Path $riskPath -NoTypeInformation
    Write-Host "Security risks exported to: $riskPath" -ForegroundColor Yellow
} else {
    Write-Host "No critical security risks identified" -ForegroundColor Green
}

# Display summary statistics
$stats = @{
    TotalUsers = $auditResults.Count
    AdminUsers = ($auditResults | Where-Object { $_.IsAdmin }).Count
    ExternalUsers = ($auditResults | Where-Object { $_.UserType -eq "External" }).Count
    ExternalAdmins = ($auditResults | Where-Object { $_.UserType -eq "External" -and $_.IsAdmin }).Count
    InactiveAccounts = ($auditResults | Where-Object { $_.AccountEnabled -eq "Inactive" }).Count
}

Write-Host "`nAudit Summary:" -ForegroundColor Cyan
$stats.GetEnumerator() | ForEach-Object {
    Write-Host "  $($_.Key): $($_.Value)" -ForegroundColor White
}

Auditoría de las conexiones y credenciales del servicio

Este script audita las conexiones de servicio e identifica posibles riesgos de seguridad:

# Service Connection Security Audit Script
param(
    [Parameter(Mandatory=$true)]
    [string]$organization,
    [string]$outputPath = "C:\AzureDevOps\ServiceConnectionAudit.csv",
    [string]$apiVersion = "7.1-preview"
)

# Authenticate and setup
if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
    Connect-AzAccount
}

$resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
$token = (Get-AzAccessToken -ResourceUrl $resourceUrl).Token
$headers = @{
    Authorization = "Bearer $token"
    "Content-Type" = "application/json"
}

Write-Host "Starting service connection audit for organization: $organization" -ForegroundColor Cyan

$allServiceConnections = @()
$securityRisks = @()

# Get all projects to scan their service connections
$projectsUrl = "https://dev.azure.com/$organization/_apis/projects?api-version=$apiVersion"
$projects = Invoke-RestMethod -Uri $projectsUrl -Method Get -Headers $headers

foreach ($project in $projects.value) {
    Write-Host "Scanning project: $($project.name)" -ForegroundColor Yellow
    
    try {
        # Get service connections for this project
        $serviceConnUrl = "https://dev.azure.com/$organization/$($project.name)/_apis/serviceendpoint/endpoints?api-version=$apiVersion"
        $serviceConnections = Invoke-RestMethod -Uri $serviceConnUrl -Method Get -Headers $headers
        
        foreach ($conn in $serviceConnections.value) {
            # Analyze service connection security
            $riskLevel = "Low"
            $risks = @()
            
            # Check for risky configurations
            if ($conn.isShared -eq $true) {
                $risks += "Shared across projects"
                $riskLevel = "Medium"
            }
            
            if ($conn.authorization.scheme -eq "UsernamePassword") {
                $risks += "Uses username/password authentication"
                $riskLevel = "High"
            }
            
            if ($conn.isReady -eq $false) {
                $risks += "Connection not verified/ready"
                $riskLevel = "Medium"
            }
            
            # Check last used date
            $lastUsed = "Unknown"
            if ($conn.operationStatus.state -eq "Ready") {
                # Attempt to get usage data (may require additional API calls)
                $lastUsed = "Recently used"
            }
            
            # Check for overly permissive access
            if ($conn.authorization.parameters.scope -like "*.*" -or $conn.authorization.parameters.scope -eq "FullAccess") {
                $risks += "Overly broad scope/permissions"
                $riskLevel = "High"
            }
            
            $connectionAudit = [PSCustomObject]@{
                Project = $project.name
                Name = $conn.name
                Type = $conn.type
                Url = $conn.url
                AuthScheme = $conn.authorization.scheme
                IsShared = $conn.isShared
                IsReady = $conn.isReady
                CreatedBy = $conn.createdBy.displayName
                LastUsed = $lastUsed
                RiskLevel = $riskLevel
                SecurityRisks = ($risks -join "; ")
            }
            
            $allServiceConnections += $connectionAudit
            
            # Add to security risks if high/medium risk
            if ($riskLevel -in @("High", "Medium")) {
                $securityRisks += $connectionAudit
            }
        }
    }
    catch {
        Write-Warning "Failed to scan project $($project.name): $($_.Exception.Message)"
    }
}

# Generate reports
Write-Host "Generating service connection audit reports..." -ForegroundColor Yellow

# Export full audit
$allServiceConnections | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Service connection audit exported to: $outputPath" -ForegroundColor Green

# Export security risks
if ($securityRisks.Count -gt 0) {
    Write-Host "Service connection security risks identified:" -ForegroundColor Red
    $securityRisks | Format-Table -AutoSize
    
    $riskPath = $outputPath.Replace('.csv', '_SecurityRisks.csv')
    $securityRisks | Export-Csv -Path $riskPath -NoTypeInformation
    Write-Host "Security risks exported to: $riskPath" -ForegroundColor Yellow
} else {
    Write-Host "No critical service connection risks identified" -ForegroundColor Green
}

# Display summary
$summary = @{
    TotalConnections = $allServiceConnections.Count
    SharedConnections = ($allServiceConnections | Where-Object { $_.IsShared }).Count
    HighRiskConnections = ($securityRisks | Where-Object { $_.RiskLevel -eq "High" }).Count
    MediumRiskConnections = ($securityRisks | Where-Object { $_.RiskLevel -eq "Medium" }).Count
    NotReadyConnections = ($allServiceConnections | Where-Object { $_.IsReady -eq $false }).Count
}

Write-Host "`nService Connection Summary:" -ForegroundColor Cyan
$summary.GetEnumerator() | ForEach-Object {
    Write-Host "  $($_.Key): $($_.Value)" -ForegroundColor White
}

Escanear las dependencias en busca de vulnerabilidades de seguridad

Comprobación de dependencias vulnerables

Este script comprueba si hay dependencias obsoletas o vulnerables mediante NuGet y npm:

# Dependency checker script
param(
    [string]$projectPath = "C:\AzureDevOps\Repo",
    [string]$outputPath = "C:\AzureDevOps\DependencyResults.json"
)

$results = @{
    NuGet = @()
    Npm = @()
    Vulnerabilities = @()
}

Write-Host "Starting dependency check in: $projectPath"

# NuGet dependency check
$nugetFiles = Get-ChildItem -Path $projectPath -Recurse -Name "*.csproj", "packages.config"
if ($nugetFiles.Count -gt 0) {
    Write-Host "Checking NuGet dependencies..."
    try {
        $nugetOutput = dotnet list package --outdated --include-transitive 2>&1
        if ($LASTEXITCODE -eq 0) {
            $results.NuGet = $nugetOutput
        }
        
        # Check for known vulnerabilities
        $vulnOutput = dotnet list package --vulnerable 2>&1
        if ($vulnOutput -like "*vulnerable*") {
            $results.Vulnerabilities += $vulnOutput
        }
    }
    catch {
        Write-Warning "NuGet check failed: $($_.Exception.Message)"
    }
}

# npm dependency check
$packageJsonFiles = Get-ChildItem -Path $projectPath -Recurse -Name "package.json"
if ($packageJsonFiles.Count -gt 0) {
    Write-Host "Checking npm dependencies..."
    Push-Location $projectPath
    try {
        $npmOutdated = npm outdated --json 2>$null
        if ($npmOutdated) {
            $results.Npm = $npmOutdated | ConvertFrom-Json
        }
        
        # Check for vulnerabilities
        $npmAudit = npm audit --json 2>$null
        if ($npmAudit) {
            $auditResults = $npmAudit | ConvertFrom-Json
            if ($auditResults.vulnerabilities) {
                $results.Vulnerabilities += $auditResults.vulnerabilities
            }
        }
    }
    catch {
        Write-Warning "npm check failed: $($_.Exception.Message)"
    }
    finally {
        Pop-Location
    }
}

# Validation Logic
$hasIssues = $false
if ($results.NuGet.Count -gt 0) {
    Write-Host "Outdated NuGet packages detected:" -ForegroundColor Yellow
    $results.NuGet | ForEach-Object { Write-Host "  $_" }
    $hasIssues = $true
}

if ($results.Npm.Count -gt 0) {
    Write-Host "Outdated npm packages detected:" -ForegroundColor Yellow
    $results.Npm | ConvertTo-Json | Write-Host
    $hasIssues = $true
}

if ($results.Vulnerabilities.Count -gt 0) {
    Write-Host "Security vulnerabilities detected:" -ForegroundColor Red
    $results.Vulnerabilities | ConvertTo-Json | Write-Host
    $hasIssues = $true
}

# Export results
$results | ConvertTo-Json -Depth 5 | Out-File $outputPath
Write-Host "Results exported to: $outputPath"

if ($hasIssues) {
    Write-Host "Issues detected. Review the output above." -ForegroundColor Red
    # Remediation Hook: Create work items or PRs
    # Example: Create a GitHub issue or Azure DevOps task
    exit 1
} else {
    Write-Host "No dependency issues found." -ForegroundColor Green
    exit 0
}

Scripts administrativos de seguridad

Acción Uso rápido Propósito
Cambiar la visibilidad del proyecto: cambiar la visibilidad del proyecto (privada pública ↔) az login
pwsh (consulte script en este artículo) -Organization "myorg" -ProjectId "myprojectid" -Visibility "private"
Automatización o actualizaciones masivas al cambiar la configuración de visibilidad de muchos proyectos
Quitar miembros del grupo: Quitar miembro del grupo de Azure DevOps az login
pwsh (consulte script en este artículo) -Organization "myorg" -GroupId "<group-guid>" -MemberId "<member-descriptor>"
Elimine un miembro específico de la atribución de grupo (use con precaución; mantenga el registro de auditoría)
Reasignar elementos de trabajo: Reasignación masiva de elementos de trabajo a un nuevo responsable az login
pwsh (consulte script en este artículo) -Organization "myorg" -Project "myproject" -WorkItemIds 123,456 -NewAssignee "Jane Doe <jane@contoso.com>"
Reasignar elementos de trabajo activos antes de quitar o deshabilitar el usuario para evitar interrupciones del flujo de trabajo

Cambio de la visibilidad del proyecto (público ↔ privado) mediante el identificador de Entra de Microsoft

Este script cambia la visibilidad del proyecto mediante un token de id. de Microsoft Entra:

# Change Project Visibility Script using Microsoft Entra ID Token
param(
    [Parameter(Mandatory=$true)]
    [string]$organization,
    [Parameter(Mandatory=$true)]
    [string]$projectId,
    [Parameter(Mandatory=$true)]
    [ValidateSet("public", "private")]
    [string]$visibility,
    [string]$apiVersion = "6.0"
)

# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan

try {
    # Get access token for Azure DevOps resource
    $resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"  # Azure DevOps resource ID
    $tokenResponse = az account get-access-token --resource $resourceUrl --output json
    
    if ($LASTEXITCODE -ne 0) {
        Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
        exit 1
    }
    
    $tokenInfo = $tokenResponse | ConvertFrom-Json
    $accessToken = $tokenInfo.accessToken
    
    Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
    Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
    exit 1
}

# Setup headers
$headers = @{
    Authorization = "Bearer $accessToken"
    "Content-Type" = "application/json"
}

# Get current project details first
Write-Host "Getting current project details..." -ForegroundColor Yellow

try {
    $projectUrl = "https://dev.azure.com/$organization/_apis/projects/$projectId" + "?api-version=$apiVersion"
    $currentProject = Invoke-RestMethod -Uri $projectUrl -Method Get -Headers $headers
    
    Write-Host "Current project '$($currentProject.name)' visibility: $($currentProject.visibility)" -ForegroundColor White
}
catch {
    Write-Error "Failed to get project details: $($_.Exception.Message)"
    Write-Error "Make sure the organization name and project ID are correct, and you have proper permissions"
    exit 1
}

# Check if change is needed
if ($currentProject.visibility -eq $visibility) {
    Write-Host "Project is already $visibility. No changes needed." -ForegroundColor Green
    exit 0
}

# Validate organization policy for public projects
if ($visibility -eq "public") {
    Write-Host "Checking if organization allows public projects..." -ForegroundColor Yellow
    
    try {
        $policiesUrl = "https://dev.azure.com/$organization/_apis/policy/configurations?api-version=6.0"
        $policies = Invoke-RestMethod -Uri $policiesUrl -Method Get -Headers $headers
        
        # Note: This is a simplified check. In practice, you might need to check specific policy types
        Write-Host "Organization policy check completed" -ForegroundColor Green
    }
    catch {
        Write-Warning "Could not verify organization public project policy. Proceeding with caution..."
    }
}

# Prepare update payload
$updatePayload = @{
    name = $currentProject.name
    description = $currentProject.description
    visibility = $visibility
} | ConvertTo-Json

Write-Host "Updating project visibility to '$visibility'..." -ForegroundColor Cyan

try {
    # Update project visibility
    $updateUrl = "https://dev.azure.com/$organization/_apis/projects/$projectId" + "?api-version=$apiVersion"
    $response = Invoke-RestMethod -Uri $updateUrl -Method Patch -Headers $headers -Body $updatePayload
    
    # Check operation status
    if ($response.status -eq "succeeded") {
        Write-Host "Project visibility successfully changed to '$visibility'" -ForegroundColor Green
    }
    elseif ($response.status -eq "queued" -or $response.status -eq "inProgress") {
        Write-Host "Project visibility change is in progress..." -ForegroundColor Yellow
        
        # Poll for completion
        $operationUrl = $response.url
        $maxAttempts = 30
        $attempt = 0
        
        do {
            Start-Sleep -Seconds 2
            $attempt++
            
            try {
                $operationStatus = Invoke-RestMethod -Uri $operationUrl -Method Get -Headers $headers
                Write-Host "  Attempt $attempt/$maxAttempts - Status: $($operationStatus.status)" -ForegroundColor Gray
                
                if ($operationStatus.status -eq "succeeded") {
                    Write-Host "Project visibility successfully changed to '$visibility'" -ForegroundColor Green
                    break
                }
                elseif ($operationStatus.status -eq "failed") {
                    Write-Error "Project visibility change failed: $($operationStatus.resultMessage)"
                    exit 1
                }
            }
            catch {
                Write-Warning "Could not check operation status: $($_.Exception.Message)"
            }
        } while ($attempt -lt $maxAttempts)
        
        if ($attempt -ge $maxAttempts) {
            Write-Warning "Operation status check timed out. Please verify the change manually in Azure DevOps."
        }
    }
    else {
        Write-Error "Unexpected response status: $($response.status)"
        exit 1
    }
}
catch {
    $errorMessage = $_.Exception.Message
    
    # Parse specific error scenarios
    if ($errorMessage -like "*403*" -or $errorMessage -like "*Forbidden*") {
        Write-Error "Access denied. You need 'Project Administrator' or higher permissions to change project visibility."
    }
    elseif ($errorMessage -like "*public projects*") {
        Write-Error "Organization policy doesn't allow public projects. Enable 'Allow public projects' in Organization Settings > Policies."
    }
    else {
        Write-Error "Failed to update project visibility: $errorMessage"
    }
    
    exit 1
}

# Verify the change
Write-Host "Verifying project visibility change..." -ForegroundColor Yellow

try {
    Start-Sleep -Seconds 3  # Give the system time to update
    $verifyProject = Invoke-RestMethod -Uri $projectUrl -Method Get -Headers $headers
    
    if ($verifyProject.visibility -eq $visibility) {
        Write-Host "Verification successful: Project is now $visibility" -ForegroundColor Green
        
        # Display important warnings for public projects
        if ($visibility -eq "public") {
            Write-Host "`nIMPORTANT SECURITY NOTICE:" -ForegroundColor Red
            Write-Host "- This project and ALL its contents are now publicly visible on the internet" -ForegroundColor Yellow
            Write-Host "- Repository code, work items, wikis, and artifacts are accessible to anyone" -ForegroundColor Yellow
            Write-Host "- 'Deny' permissions are not enforced for public projects" -ForegroundColor Yellow
            Write-Host "- Review all content for sensitive information before making public" -ForegroundColor Yellow
        }
    }
    else {
        Write-Warning "Verification failed: Project visibility is still $($verifyProject.visibility)"
    }
}
catch {
    Write-Warning "Could not verify the change: $($_.Exception.Message)"
}

Write-Host "`nProject visibility change completed." -ForegroundColor Cyan

Quitar miembros del grupo

Este script quita un miembro de un grupo de Azure DevOps:

# Remove Azure DevOps Group Member Script
param(
    [Parameter(Mandatory=$true)]
    [string]$organization,
    [Parameter(Mandatory=$true)]
    [string]$groupId,
    [Parameter(Mandatory=$true)]
    [string]$memberId,
    [string]$apiVersion = "7.1-preview",
    [switch]$WhatIf
)

# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan

try {
    $resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
    $tokenResponse = az account get-access-token --resource $resourceUrl --output json
    
    if ($LASTEXITCODE -ne 0) {
        Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
        exit 1
    }
    
    $tokenInfo = $tokenResponse | ConvertFrom-Json
    $accessToken = $tokenInfo.accessToken
    
    Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
    Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
    exit 1
}

# Setup headers
$headers = @{
    Authorization = "Bearer $accessToken"
    "Content-Type" = "application/json"
}

Write-Host "Starting group member removal process..." -ForegroundColor Cyan

try {
    # Get group information
    $groupUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups/$groupId" + "?api-version=$apiVersion"
    $group = Invoke-RestMethod -Uri $groupUrl -Method Get -Headers $headers
    
    Write-Host "Target group: $($group.displayName) ($($group.principalName))" -ForegroundColor Yellow
    
    # Get member information
    $memberUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/users/$memberId" + "?api-version=$apiVersion"
    try {
        $member = Invoke-RestMethod -Uri $memberUrl -Method Get -Headers $headers
        Write-Host "Target member: $($member.displayName) ($($member.mailAddress))" -ForegroundColor Yellow
    }
    catch {
        # Try as a group instead of user
        $memberUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/groups/$memberId" + "?api-version=$apiVersion"
        $member = Invoke-RestMethod -Uri $memberUrl -Method Get -Headers $headers
        Write-Host "Target member (group): $($member.displayName) ($($member.principalName))" -ForegroundColor Yellow
    }
    
    # Check if member is actually in the group
    $membershipsUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$memberId" + "?direction=up&api-version=$apiVersion"
    $memberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
    
    $isMember = $false
    foreach ($membership in $memberships.value) {
        if ($membership.containerDescriptor -eq $group.descriptor) {
            $isMember = $true
            break
        }
    }
    
    if (-not $isMember) {
        Write-Host "Member is not currently in the specified group. No action needed." -ForegroundColor Green
        exit 0
    }
    
    # Show what will be done
    Write-Host "`nOperation Summary:" -ForegroundColor Cyan
    Write-Host "  Action: Remove member from group" -ForegroundColor White
    Write-Host "  Group: $($group.displayName)" -ForegroundColor White
    Write-Host "  Member: $($member.displayName)" -ForegroundColor White
    Write-Host "  Organization: $organization" -ForegroundColor White
    
    if ($WhatIf) {
        Write-Host "`n[WHAT-IF] Would remove member from group (no actual changes made)" -ForegroundColor Yellow
        exit 0
    }
    
    # Confirm before proceeding
    Write-Host "`nWARNING: This action will remove the member from the group and may affect their access to projects and resources." -ForegroundColor Red
    $confirmation = Read-Host "Are you sure you want to proceed? (y/N)"
    
    if ($confirmation -notmatch '^[Yy]') {
        Write-Host "Operation cancelled by user." -ForegroundColor Yellow
        exit 0
    }
    
    # Remove member from group
    Write-Host "`nRemoving member from group..." -ForegroundColor Cyan
    
    $removeUrl = "https://vssps.dev.azure.com/$organization/_apis/graph/memberships/$memberId/$($group.descriptor)" + "?api-version=$apiVersion"
    
    try {
        Invoke-RestMethod -Uri $removeUrl -Method Delete -Headers $headers
        Write-Host "Successfully removed member from group" -ForegroundColor Green
        
        # Verify removal
        Start-Sleep -Seconds 2
        $verifyMemberships = Invoke-RestMethod -Uri $membershipsUrl -Method Get -Headers $headers
        
        $stillMember = $false
        foreach ($membership in $verifyMemberships.value) {
            if ($membership.containerDescriptor -eq $group.descriptor) {
                $stillMember = $true
                break
            }
        }
        
        if (-not $stillMember) {
            Write-Host "Verification successful: Member has been removed from the group" -ForegroundColor Green
        }
        else {
            Write-Warning "Verification failed: Member may still be in the group"
        }
        
        # Log the action for audit trail
        $auditEntry = [PSCustomObject]@{
            Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            Action = "Remove Group Member"
            Organization = $organization
            GroupName = $group.displayName
            GroupId = $groupId
            MemberName = $member.displayName
            MemberId = $memberId
            ExecutedBy = (az account show --query user.name -o tsv)
            Status = "Success"
        }
        
        $auditPath = "C:\AzureDevOps\GroupMemberRemoval_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
        $auditEntry | ConvertTo-Json | Out-File $auditPath
        Write-Host "Audit log saved to: $auditPath" -ForegroundColor Green
        
    }
    catch {
        $errorMessage = $_.Exception.Message
        
        if ($errorMessage -like "*403*" -or $errorMessage -like "*Forbidden*") {
            Write-Error "Access denied. You need appropriate permissions to manage group memberships."
        }
        elseif ($errorMessage -like "*404*" -or $errorMessage -like "*Not Found*") {
            Write-Error "Group or member not found. Please verify the IDs are correct."
        }
        else {
            Write-Error "Failed to remove member from group: $errorMessage"
        }
        
        exit 1
    }
}
catch {
    Write-Error "Failed to process group member removal: $($_.Exception.Message)"
    exit 1
}

Write-Host "`nGroup member removal completed." -ForegroundColor Cyan

Reasignación de elementos de trabajo

Este script reasigna los elementos de trabajo a un nuevo asignado:

# Reassign Work Items Script
param(
    [Parameter(Mandatory=$true)]
    [string]$organization,
    [Parameter(Mandatory=$true)]
    [string]$project,
    [Parameter(Mandatory=$true)]
    [int[]]$workItemIds,
    [Parameter(Mandatory=$true)]
    [string]$newAssignee,
    [string]$apiVersion = "7.1-preview",
    [switch]$WhatIf
)

# Authenticate and get Microsoft Entra ID token
Write-Host "Authenticating to Azure..." -ForegroundColor Cyan

try {
    $resourceUrl = "499b84ac-1321-427f-aa17-267ca6975798"
    $tokenResponse = az account get-access-token --resource $resourceUrl --output json
    
    if ($LASTEXITCODE -ne 0) {
        Write-Error "Failed to get access token. Make sure you're logged in with 'az login'"
        exit 1
    }
    
    $tokenInfo = $tokenResponse | ConvertFrom-Json
    $accessToken = $tokenInfo.accessToken
    
    Write-Host "Successfully obtained Microsoft Entra ID access token" -ForegroundColor Green
}
catch {
    Write-Error "Failed to authenticate with Microsoft Entra ID: $($_.Exception.Message)"
    exit 1
}

# Setup headers
$headers = @{
    Authorization = "Bearer $accessToken"
    "Content-Type" = "application/json-patch+json"
}

Write-Host "Starting work item reassignment process..." -ForegroundColor Cyan

$successfulUpdates = @()
$failedUpdates = @()

foreach ($workItemId in $workItemIds) {
    try {
        # Get current work item details
        $workItemUrl = "https://dev.azure.com/$organization/$project/_apis/wit/workitems/$workItemId" + "?api-version=$apiVersion"
        $workItem = Invoke-RestMethod -Uri $workItemUrl -Method Get -Headers $headers
        
        $currentAssignee = if ($workItem.fields.'System.AssignedTo') { 
            $workItem.fields.'System.AssignedTo'.displayName 
        } else { 
            "Unassigned" 
        }
        
        Write-Host "`nWork Item $workItemId ($($workItem.fields.'System.Title'))" -ForegroundColor Yellow
        Write-Host "  Current Assignee: $currentAssignee" -ForegroundColor White
        Write-Host "  New Assignee: $newAssignee" -ForegroundColor White
        Write-Host "  State: $($workItem.fields.'System.State')" -ForegroundColor White
        Write-Host "  Work Item Type: $($workItem.fields.'System.WorkItemType')" -ForegroundColor White
        
        if ($WhatIf) {
            Write-Host "  [WHAT-IF] Would reassign to $newAssignee" -ForegroundColor Yellow
            continue
        }
        
        # Skip if already assigned to the target user
        if ($workItem.fields.'System.AssignedTo' -and $workItem.fields.'System.AssignedTo'.displayName -eq $newAssignee) {
            Write-Host "  Already assigned to $newAssignee - skipping" -ForegroundColor Green
            continue
        }
        
        # Prepare update payload
        $updatePayload = @(
            @{
                op = "replace"
                path = "/fields/System.AssignedTo"
                value = $newAssignee
            }
        )
        
        # Add a comment about the reassignment
        $comment = "Work item reassigned from '$currentAssignee' to '$newAssignee' via automation on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
        $updatePayload += @{
            op = "add"
            path = "/fields/System.History"
            value = $comment
        }
        
        $updateBody = $updatePayload | ConvertTo-Json -Depth 3
        
        # Update the work item
        $updateResponse = Invoke-RestMethod -Uri $workItemUrl -Method Patch -Headers $headers -Body $updateBody
        
        Write-Host "  ✓ Successfully reassigned" -ForegroundColor Green
        
        $successfulUpdates += [PSCustomObject]@{
            WorkItemId = $workItemId
            Title = $workItem.fields.'System.Title'
            PreviousAssignee = $currentAssignee
            NewAssignee = $newAssignee
            WorkItemType = $workItem.fields.'System.WorkItemType'
            State = $workItem.fields.'System.State'
            UpdatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        }
        
    }
    catch {
        $errorMessage = $_.Exception.Message
        Write-Host "  ✗ Failed to reassign: $errorMessage" -ForegroundColor Red
        
        $failedUpdates += [PSCustomObject]@{
            WorkItemId = $workItemId
            Error = $errorMessage
            AttemptedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        }
    }
}

# Generate summary report
Write-Host "`n" + "="*50 -ForegroundColor Cyan
Write-Host "REASSIGNMENT SUMMARY" -ForegroundColor Cyan
Write-Host "="*50 -ForegroundColor Cyan

Write-Host "Total work items processed: $($workItemIds.Count)" -ForegroundColor White
Write-Host "Successful reassignments: $($successfulUpdates.Count)" -ForegroundColor Green
Write-Host "Failed reassignments: $($failedUpdates.Count)" -ForegroundColor Red

if ($successfulUpdates.Count -gt 0) {
    Write-Host "`nSuccessful Updates:" -ForegroundColor Green
    $successfulUpdates | Format-Table -AutoSize
    
    # Export successful updates
    $successPath = "C:\AzureDevOps\WorkItemReassignment_Success_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
    $successfulUpdates | Export-Csv -Path $successPath -NoTypeInformation
    Write-Host "Successful updates exported to: $successPath" -ForegroundColor Green
}

if ($failedUpdates.Count -gt 0) {
    Write-Host "`nFailed Updates:" -ForegroundColor Red
    $failedUpdates | Format-Table -AutoSize
    
    # Export failed updates
    $failedPath = "C:\AzureDevOps\WorkItemReassignment_Failed_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
    $failedUpdates | Export-Csv -Path $failedPath -NoTypeInformation
    Write-Host "Failed updates exported to: $failedPath" -ForegroundColor Yellow
}

# Create audit log
$auditEntry = [PSCustomObject]@{
    Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    Action = "Bulk Work Item Reassignment"
    Organization = $organization
    Project = $project
    NewAssignee = $newAssignee
    TotalWorkItems = $workItemIds.Count
    SuccessfulUpdates = $successfulUpdates.Count
    FailedUpdates = $failedUpdates.Count
    ExecutedBy = (az account show --query user.name -o tsv)
    WorkItemIds = ($workItemIds -join ", ")
}

$auditPath = "C:\AzureDevOps\WorkItemReassignment_Audit_$(Get-Date -Format 'yyyyMMdd_HHmmss').json"
$auditEntry | ConvertTo-Json -Depth 3 | Out-File $auditPath
Write-Host "`nAudit log saved to: $auditPath" -ForegroundColor Green

if ($WhatIf) {
    Write-Host "`n[WHAT-IF MODE] No actual changes were made" -ForegroundColor Yellow
}

Write-Host "`nWork item reassignment process completed." -ForegroundColor Cyan

# Exit with appropriate code
if ($failedUpdates.Count -gt 0) {
    exit 1
} else {
    exit 0
}

Soporte y auditoría

  • Mantener una pista de auditoría de los cambios automatizados
  • Almacenamiento de credenciales de forma segura (Key Vault o variables secretas de canalización)
  • Revocar tokens o principales de servicio si se necesita rotación de credenciales después de ejecutar operaciones masivas.
  • Prueba en entornos que no son de producción antes de ejecutarse en producción