自动执行安全审核和管理

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

本文包含 PowerShell 脚本,用于审核 Azure DevOps 组织中的安全性,并为常见安全任务提供管理工具。 使用这些脚本可以审核用户访问、查看服务连接、扫描依赖项是否存在漏洞,以及执行特定的管理任务,例如更改项目可见性和管理组成员身份。

这些脚本可帮助你保持对安全状况的可见性,识别潜在风险,并通过自动化简化选择的管理安全任务。

免责声明

为了方便起见,提供了这些脚本。 在生产环境中运行之前,请查看它们、在非生产环境中进行测试并适应组织的策略。 验证环境的正确性和安全性。

先决条件

类别 Description
Azure DevOps - Azure DevOps 组织和项目。 免费创建一个
- 权限: 组织级脚本的组织所有者或项目集合管理员,项目级操作的项目管理员
身份验证 Microsoft Entra ID 令牌。 脚本通过 Azure CLI 获取令牌: az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798。 确保已经使用az login登录
工具 - Azure CLI (az) 和 PowerShell (pwsh) 必需
- 为 PowerShell 7+编写的脚本;修改以适配 Windows PowerShell
- 某些脚本的 Azure PowerShell 模块(Install-Module -Name Az
Safety - 首先在非生产组织中进行测试
- 删除用户之前重新分配或关闭正在进行的任务
- 在公开项目之前查看项目可见性先决条件
- 保留自动更改的审计跟踪记录
Security - 安全地存储凭据和令牌(Key Vault 或管道机密变量)
- 如果需要轮换凭据,批量操作后撤销令牌/服务主体

开始

我应使用哪个脚本?

您的目标 建议的脚本 示例频率
查找安全风险 用户访问审核 Monthly
清理权限 删除组成员 根据需要
准备用户离开 删除组成员重新分配工作项 根据需要
检查连接 服务连接审核 每季度
检查依赖项 依赖项扫描程序 Weekly

审核整个组织的安全性和访问权限

审核用户访问和权限设置

此脚本审核并报告整个组织中的用户权限:

# 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
}

审核服务连接和凭据

此脚本会审核服务连接并识别潜在的安全风险:

# 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
}

扫描依赖项是否存在安全漏洞

检查易受攻击的依赖项

此脚本使用 NuGet 和 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
}

管理安全脚本

Action 快速使用 目的
更改项目可见性:切换项目可见性(公共↔专用) az login
pwsh (请参阅本文中的脚本) -Organization "myorg" -ProjectId "myprojectid" -Visibility "private"
更改多个项目的可见性设置时自动化/批量更新
删除组成员:从 Azure DevOps 组中删除成员 az login
pwsh (请参阅本文中的脚本) -Organization "myorg" -GroupId "<group-guid>" -MemberId "<member-descriptor>"
从组的权限中删除特定成员(使用时请谨慎,并保留审核线索)
重新分配工作项:将工作项批量重新分配给新的被分配者 az login
pwsh (请参阅本文中的脚本) -Organization "myorg" -Project "myproject" -WorkItemIds 123,456 -NewAssignee "Jane Doe <jane@contoso.com>"
在删除/禁用用户之前重新分配活动工作项以避免工作流中断

使用 Microsoft Entra ID 更改项目可见性(公共↔专用)

此脚本使用 Microsoft Entra ID 令牌更改项目可见性:

# 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

删除组成员

此脚本从 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

重新分配工作项

此脚本将工作项重新分配给新的被分配者:

# 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
}

支持和审核

  • 保留自动更改的审核线索
  • 安全地存储凭据 (Key Vault 或管道机密变量)
  • 如果在运行批量操作后需要凭据轮换,请撤销令牌/服务主体
  • 在生产环境中运行之前,在非生产环境中进行测试