You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

请求排查Azure DevOps非活跃用户删除脚本无报错但未生效问题

Azure DevOps非活跃用户删除脚本排查与修复建议

我编写了一款PowerShell脚本,用于通过Azure DevOps对应API删除组织内的非活跃用户,同时在删除时排除管理员组中的非活跃用户。脚本执行过程无报错,但目标用户并未被删除。希望了解是否在API调用或必要参数配置方面存在遗漏,以下是相关脚本:

# Define required variables
$Organizations = @(
  "OrganizationName"
)
$AdminGroups = @(
    "ADgroup"
)
$LogFile = "$(Get-Location)\AzDO_Cleanup_Log_$(Get-Date -Format 'yyyy-MM-dd').txt"
$CsvFilePath = "$(System.DefaultWorkingDirectory)/deletedUsers.csv"
$AzureDevOpsPAT = $token
$DaysInactiveThreshold = 10  # Users inactive for more than this will be deleted

# Function to log messages to console and file
function Write-Log {
    param ([string]$Message)
    $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $LogEntry = "$Timestamp - $Message"
    Write-Host $LogEntry
    Add-Content -Path $LogFile -Value $LogEntry
}

# Function to call Azure DevOps REST API
function Invoke-AzDOApi {
    param (
        [string]$Uri,
        [string]$Method
    )
    $Headers = @{Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$AzureDevOpsPAT")) }
    try {
        return Invoke-RestMethod -Uri $Uri -Headers $Headers -Method $Method -ContentType "application/json"
    } catch {
        Write-Log "Error calling API $Uri - $_"
        return $null
    }
}

# Get the cutoff date for inactivity
$CutoffDate = (Get-Date).AddDays(-$DaysInactiveThreshold)

# Loop through each organization
$DeletedUsers = @()
foreach ($Org in $Organizations) {
    Write-Log "Processing organization: $Org"
    
    # Step 1: Retrieve all users
    $UsersUri = "https://vsaex.dev.azure.com/$Org/_apis/memberentitlements?api-version=7.1-preview.2"
    $Users = Invoke-AzDOApi -Uri $UsersUri -Method "GET"

    if (-not $Users -or -not $Users.items) {
        Write-Log "No users found or API call failed for $Org. Skipping..."
        continue
    }

    # Step 2: Retrieve admin group descriptors
    $AdminDescriptors = @()
    $GroupsUri = "https://vssps.dev.azure.com/$Org/_apis/graph/groups?api-version=7.1-preview.1"
    $GroupsData = Invoke-AzDOApi -Uri $GroupsUri -Method "GET"

    foreach ($Group in $AdminGroups) {
        $GroupDescriptor = ($GroupsData.value | Where-Object { $_.displayName -eq $Group }).descriptor
        if ($GroupDescriptor) { $AdminDescriptors += $GroupDescriptor }
    }

    # Step 3: Filter inactive users
    foreach ($User in $Users.items) {
        $displayName = $User.member.displayName
        $email = $User.member.mailAddress
        $status = $User.accessLevel.status
        $lastAccess = $User.lastAccessedDate
        $userDescriptor = $User.member.descriptor

        Write-Log "Checking user: $displayName ($email)"

        # Skip active users
        if ($status -eq "active") {
            Write-Log "User $displayName is active. Skipping..."
            continue
        }

        # Convert last access date to DateTime object
        if ($lastAccess) {
            $LastAccessedDate = [DateTime]$lastAccess
        } else {
            Write-Log "User $displayName has no recorded login activity. Treating as inactive."
            $LastAccessedDate = (Get-Date).AddYears(-10)  # Consider users with no activity as very old
        }

        # Check if user is inactive (last login > 10 days ago)
        if ($LastAccessedDate -gt $CutoffDate) {
            Write-Log "User $displayName logged in within the last $DaysInactiveThreshold days. Skipping..."
            continue
        }

        # Step 4: Check if user belongs to any admin group
        $MembershipUri = "https://vssps.dev.azure.com/$Org/_apis/graph/memberships/$userDescriptor?api-version=7.1-preview.1"
        $Memberships = Invoke-AzDOApi -Uri $MembershipUri -Method "GET"

        $IsAdmin = $false
        foreach ($Membership in $Memberships.value) {
            if ($Membership.containerDescriptor -in $AdminDescriptors) {
                $IsAdmin = $true
                break
            }
        }

        # Step 5: Delete non-admin inactive users
        if (-not $IsAdmin) {
            Write-Log "Deleting inactive user: $displayName ($email) - Last Login: $LastAccessedDate"
            $DeleteUri = "https://vsaex.dev.azure.com/$Org/_apis/userentitlements/$userDescriptor?api-version=7.1"
            Invoke-AzDOApi -Uri $DeleteUri -Method "DELETE"

            # Log deletion
            $DeletedUsers += [PSCustomObject]@{
                Organization = $Org
                DisplayName  = $displayName
                Email        = $email
                Status       = $status
                LastAccessed = $LastAccessedDate
                DeletedOn    = Get-Date
            }
        } else {
            Write-Log "Skipping admin user: $displayName ($email)"
        }
    }

    Write-Log "Finished processing organization: $Org"
    Write-Log "----------------------------------------"
}

# Step 6: Export to CSV if any users were deleted
if ($DeletedUsers.Count -gt 0) {
    if (Test-Path $CsvFilePath) {
        $DeletedUsers | Export-Csv -Path $CsvFilePath -NoTypeInformation -Append
    } else {
        $DeletedUsers | Export-Csv -Path $CsvFilePath -NoTypeInformation
    }
    Write-Log "Deleted users logged to $CsvFilePath"
} else {
    Write-Log "No inactive users deleted, skipping CSV creation."
}  

排查与修复建议

1. 删除API端点错误

脚本中使用的删除端点https://vsaex.dev.azure.com/$Org/_apis/userentitlements/$userDescriptor?api-version=7.1不符合Azure DevOps API规范。正确的删除用户授权接口应为成员授权(Member Entitlements),需要使用memberEntitlementId而非用户descriptor:

# 替换原DeleteUri为:
$DeleteUri = "https://vsaex.dev.azure.com/$Org/_apis/memberentitlements/$($User.id)?api-version=7.1"

说明:$User.id是从memberentitlements接口返回的items.id字段,这才是删除用户授权所需的唯一标识。

2. 验证PAT权限

确保你的PAT拥有以下核心权限,否则会出现调用无报错但无实际操作的情况:

  • Member Entitlement Management:勾选「读写」权限
  • Graph:勾选「读写」权限

3. 管理员组成员检查逻辑缺陷

当前脚本仅检查用户的直接组成员关系,若用户通过嵌套组继承管理员权限,会被误判为非管理员;同时组名匹配可能因大小写/空格问题失败:

修复点:

  • 改用支持嵌套组查询的API:
# 替换原MembershipUri为:
$MembershipUri = "https://vssps.dev.azure.com/$Org/_apis/graph/memberships/$userDescriptor/transitive?api-version=7.1-preview.1"
  • 组名匹配添加不区分大小写规则:
$GroupDescriptor = ($GroupsData.value | Where-Object { $_.displayName -ieq $Group }).descriptor

4. 日期转换与比较的时区偏差

API返回的lastAccessedDate是UTC格式字符串,直接转换为[DateTime]可能因本地时区导致判断错误:

修复点:

# 带时区的日期转换
if ($lastAccess) {
    $LastAccessedDate = [DateTime]::Parse($lastAccess, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal)
}
# 同时将CutoffDate转换为UTC时间
$CutoffDate = (Get-Date).ToUniversalTime().AddDays(-$DaysInactiveThreshold)

5. 增强API调用的错误验证

当前函数对DELETE请求的成功判断不足,DELETE请求成功时Invoke-RestMethod无返回内容,导致调用失败(如403、404)被忽略。修改函数:

function Invoke-AzDOApi {
    param (
        [string]$Uri,
        [string]$Method
    )
    $Headers = @{Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$AzureDevOpsPAT")) }
    try {
        if ($Method -eq "DELETE") {
            $response = Invoke-RestMethod -Uri $Uri -Headers $Headers -Method $Method -ContentType "application/json" -StatusCodeVariable statusCode
            if ($statusCode -ne 204) {
                Write-Log "DELETE request to $Uri returned unexpected status code: $statusCode"
                return $null
            }
            return $response
        } else {
            return Invoke-RestMethod -Uri $Uri -Headers $Headers -Method $Method -ContentType "application/json"
        }
    } catch {
        Write-Log "Error calling API $Uri - Status Code: $($_.Exception.Response.StatusCode.value__), Message: $($_.Exception.Message)"
        return $null
    }
}

6. 用户状态判断的完整性

脚本仅判断$status -eq "active",但Azure DevOps用户授权状态还有pending等其他活跃类状态,建议扩展判断:

# 替换原跳过活跃用户的逻辑:
if ($status -in "active", "pending") {
    Write-Log "User $displayName is in active/pending status. Skipping..."
    continue
}

内容的提问来源于stack exchange,提问作者user25518631

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 09:58:09