Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codebundles/azure-devops-project-health/_az_helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

: "${AZ_RETRY_COUNT:=3}"
: "${AZ_RETRY_INITIAL_WAIT:=5}"
: "${AZ_CMD_TIMEOUT:=60}"
: "${AZ_CMD_TIMEOUT:=30}"

# Run an az CLI command with retry and per-call timeout.
# Usage: az_with_retry az pipelines list --output json
Expand Down
287 changes: 176 additions & 111 deletions codebundles/azure-devops-project-health/preflight-check.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
#!/usr/bin/env bash
# Preflight check: validates identity, API connectivity, and per-scope access
# for each project before the main health checks run.
# Preflight check: identifies the authenticated identity and enumerates
# actual group memberships (roles) using the Azure DevOps REST API.
#
# Instead of "try an API and see if it works", this lists the concrete
# roles the identity holds -- which is defensible and actionable when
# troubleshooting permission issues.
#
# REQUIRED ENV VARS:
# AZURE_DEVOPS_ORG
# AZURE_DEVOPS_PROJECTS - comma-separated project names to validate
#
# Outputs preflight_results.json with identity info and per-scope access results.
# Outputs preflight_results.json with identity, group memberships, and
# per-project role summary.

set -uo pipefail

: "${AZURE_DEVOPS_ORG:?Must set AZURE_DEVOPS_ORG}"
: "${AZURE_DEVOPS_PROJECTS:?Must set AZURE_DEVOPS_PROJECTS}"
Expand All @@ -18,154 +25,212 @@ source "$(dirname "$0")/_az_helpers.sh"

OUTPUT_FILE="preflight_results.json"
ORG_URL="https://dev.azure.com/$AZURE_DEVOPS_ORG"
VSSPS_URL="https://vssps.dev.azure.com/$AZURE_DEVOPS_ORG"

setup_azure_auth

# --- Identity info ---
echo "=== Identifying logged-in account ==="
identity_json='{}'
build_auth_header() {
if [ "$AUTH_TYPE" = "pat" ]; then
printf "Basic %s" "$(printf ':%s' "$AZURE_DEVOPS_EXT_PAT" | base64 -w0)"
else
local token
token=$(az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query accessToken -o tsv 2>/dev/null || echo "")
if [ -n "$token" ]; then
printf "Bearer %s" "$token"
fi
fi
}

AUTH_HEADER=$(build_auth_header)

api_get() {
if [ -n "$AUTH_HEADER" ]; then
curl -s --max-time 15 -H "Authorization: $AUTH_HEADER" "$1"
else
echo '{"error": "no auth header available"}'
fi
}

# =========================================================================
# 1. Identify the authenticated user via _apis/connectionData
# =========================================================================
echo "=== Authenticated Identity ==="
identity_json='{"name":"unknown","id":"unknown","auth_type":"'"$AUTH_TYPE"'","error":"not retrieved"}'
subject_descriptor=""

conn_data=$(api_get "$ORG_URL/_apis/connectionData?api-version=7.1")

if echo "$conn_data" | jq -e '.authenticatedUser' &>/dev/null; then
user_display=$(echo "$conn_data" | jq -r '.authenticatedUser.providerDisplayName // "unknown"')
user_id=$(echo "$conn_data" | jq -r '.authenticatedUser.id // "unknown"')
subject_descriptor=$(echo "$conn_data" | jq -r '.authenticatedUser.subjectDescriptor // empty')

if account_info=$(az account show --output json 2>/dev/null); then
identity_name=$(echo "$account_info" | jq -r '.user.name // "unknown"')
identity_type=$(echo "$account_info" | jq -r '.user.type // "unknown"')
subscription=$(echo "$account_info" | jq -r '.name // "unknown"')
tenant_id=$(echo "$account_info" | jq -r '.tenantId // "unknown"')
echo " Display Name: $user_display"
echo " User ID: $user_id"
echo " Auth Type: $AUTH_TYPE"

identity_json=$(jq -n \
--arg name "$identity_name" \
--arg type "$identity_type" \
--arg subscription "$subscription" \
--arg tenant "$tenant_id" \
--arg name "$user_display" \
--arg id "$user_id" \
--arg descriptor "${subject_descriptor:-}" \
--arg auth_type "$AUTH_TYPE" \
'{name: $name, type: $type, subscription: $subscription, tenant: $tenant, auth_type: $auth_type}')

echo " Identity: $identity_name ($identity_type)"
echo " Auth: $AUTH_TYPE"
echo " Tenant: $tenant_id"
'{name: $name, id: $id, descriptor: $descriptor, auth_type: $auth_type}')
else
echo " WARNING: Could not retrieve account info"
identity_json=$(jq -n --arg auth_type "$AUTH_TYPE" '{name: "unknown", type: "unknown", auth_type: $auth_type, error: "Could not retrieve account info"}')
echo " ERROR: Could not retrieve identity via connectionData API"
echo " Hint: Verify the PAT or service principal credentials are valid."
if echo "$conn_data" | jq -e '.message' &>/dev/null; then
api_msg=$(echo "$conn_data" | jq -r '.message' | head -c 300)
echo " API message: $api_msg"
fi
fi

# --- Organization-level access ---
# =========================================================================
# 2. Enumerate group memberships via Graph API
# =========================================================================
echo ""
echo "=== Testing organization-level access ==="
org_access='{}'

echo -n " Agent pools: "
if timeout 15 az pipelines pool list --org "$ORG_URL" --top 1 --output json &>/dev/null; then
echo "OK"
org_access=$(echo "$org_access" | jq '. + {agent_pools: "ok"}')
echo "=== Group Memberships ==="
all_groups='[]'

if [ -n "$subject_descriptor" ]; then
membership_response=$(api_get "$VSSPS_URL/_apis/graph/memberships/$subject_descriptor?direction=up&api-version=7.1-preview.1")

if echo "$membership_response" | jq -e '.value' &>/dev/null; then
member_count=$(echo "$membership_response" | jq '.value | length')
echo " Resolving $member_count group membership(s)..."
echo ""

while IFS= read -r desc; do
[ -z "$desc" ] && continue
group_info=$(api_get "$VSSPS_URL/_apis/graph/groups/$desc?api-version=7.1-preview.1")

if echo "$group_info" | jq -e '.principalName' &>/dev/null; then
principal=$(echo "$group_info" | jq -r '.principalName // "unknown"')
display=$(echo "$group_info" | jq -r '.displayName // "unknown"')
scope_field=$(echo "$group_info" | jq -r '.domain // "unknown"')

echo " - $principal"

all_groups=$(echo "$all_groups" | jq \
--arg p "$principal" \
--arg d "$display" \
--arg s "$scope_field" \
'. += [{"principalName": $p, "displayName": $d, "scope": $s}]')
fi
done < <(echo "$membership_response" | jq -r '.value[].containerDescriptor // empty')

echo ""
echo " Total: $(echo "$all_groups" | jq 'length') group(s)"
else
echo " WARNING: Could not list memberships via Graph API."
echo " The PAT may lack the Graph (Read) or Member Entitlement Management (Read) scope."
if echo "$membership_response" | jq -e '.message' &>/dev/null; then
api_msg=$(echo "$membership_response" | jq -r '.message' | head -c 300)
echo " API message: $api_msg"
fi
fi
else
echo "DENIED/FAILED"
org_access=$(echo "$org_access" | jq '. + {agent_pools: "denied_or_failed"}')
echo " SKIPPED: No identity descriptor available -- cannot enumerate memberships."
echo " This typically means the connectionData call above failed."
fi

# --- Per-project access checks ---
# =========================================================================
# 3. Per-project role summary
# =========================================================================
echo ""
echo "=== Testing per-project access ==="
project_results='[]'
echo "=== Per-Project Role Summary ==="
project_roles='[]'

IFS=',' read -ra PROJECTS <<< "$AZURE_DEVOPS_PROJECTS"
for project in "${PROJECTS[@]}"; do
project=$(echo "$project" | xargs) # trim whitespace
project=$(echo "$project" | xargs)
[ -z "$project" ] && continue

echo " Project: $project"
proj_result=$(jq -n --arg name "$project" '{project: $name}')

# Project access
echo -n " project show: "
if timeout 15 az devops project show --project "$project" --org "$ORG_URL" --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {project_access: "ok"}')
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {project_access: "denied_or_failed"}')
fi

# Pipelines read
echo -n " pipelines list: "
if timeout 15 az pipelines list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {pipelines: "ok"}')
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {pipelines: "denied_or_failed"}')
fi

# Pipeline runs read
echo -n " pipeline runs list: "
if timeout 15 az pipelines runs list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {pipeline_runs: "ok"}')
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {pipeline_runs: "denied_or_failed"}')
fi

# Repos read
echo -n " repos list: "
if timeout 15 az repos list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {repos: "ok"}')
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {repos: "denied_or_failed"}')
fi
proj_prefix="[$project]\\"
proj_groups=$(echo "$all_groups" | jq --arg p "$proj_prefix" \
'[.[] | select(.principalName | startswith($p)) | .displayName]')
count=$(echo "$proj_groups" | jq 'length')

# Service endpoints read
echo -n " service endpoints: "
if timeout 15 az devops service-endpoint list --project "$project" --org "$ORG_URL" --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {service_endpoints: "ok"}')
if [ "$count" -gt 0 ]; then
echo "$proj_groups" | jq -r '.[] | " Role: " + .'
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {service_endpoints: "denied_or_failed"}')
echo " WARNING: No project-level roles found for this identity."
echo " The identity may not be a direct member of project '$project',"
echo " or group membership enumeration was not possible."
fi

# Repo policies read
echo -n " repo policies: "
if timeout 15 az repos policy list --project "$project" --org "$ORG_URL" --output json &>/dev/null; then
echo "OK"
proj_result=$(echo "$proj_result" | jq '. + {repo_policies: "ok"}')
else
echo "DENIED/FAILED"
proj_result=$(echo "$proj_result" | jq '. + {repo_policies: "denied_or_failed"}')
fi

project_results=$(echo "$project_results" | jq --argjson proj "$proj_result" '. += [$proj]')
project_roles=$(echo "$project_roles" | jq \
--arg proj "$project" \
--argjson groups "$proj_groups" \
'. += [{"project": $proj, "roles": $groups, "role_count": ($groups | length)}]')
done

# --- Build summary ---
denied_count=$(echo "$project_results" | jq '[.[] | to_entries[] | select(.value == "denied_or_failed" and .key != "project")] | length')
org_denied=$(echo "$org_access" | jq '[to_entries[] | select(.value == "denied_or_failed")] | length')
total_denied=$((denied_count + org_denied))
# Org-level roles
echo ""
echo " Organization-level roles:"
org_prefix="[$AZURE_DEVOPS_ORG]\\"
org_roles=$(echo "$all_groups" | jq --arg o "$org_prefix" \
'[.[] | select(.principalName | startswith($o)) | .displayName]')
org_count=$(echo "$org_roles" | jq 'length')

if [ "$org_count" -gt 0 ]; then
echo "$org_roles" | jq -r '.[] | " Role: " + .'
else
echo " (none found)"
fi

# =========================================================================
# 4. Build summary
# =========================================================================
echo ""
echo "=== Preflight Summary ==="

if [ "$total_denied" -gt 0 ]; then
summary="WARNING: $total_denied API scope(s) returned denied or failed. Some health checks may produce incomplete results."
total_groups=$(echo "$all_groups" | jq 'length')
projects_with_roles=$(echo "$project_roles" | jq '[.[] | select(.role_count > 0)] | length')
total_projects=$(echo "$project_roles" | jq 'length')
user_name=$(echo "$identity_json" | jq -r '.name')

if [ "$total_groups" -eq 0 ] && [ -n "$subject_descriptor" ]; then
summary="WARNING: Identity '$user_name' authenticated successfully but has 0 group memberships. Check Graph API scope on the PAT."
elif [ "$total_groups" -eq 0 ]; then
summary="ERROR: Could not identify the authenticated user or enumerate permissions. Check credentials."
elif [ "$projects_with_roles" -lt "$total_projects" ]; then
missing=$(echo "$project_roles" | jq -r '[.[] | select(.role_count == 0) | .project] | join(", ")')
summary="WARNING: Identity '$user_name' has $total_groups group(s) but no project-level roles in: $missing. These projects may produce incomplete results."
else
summary="All API scopes accessible. Preflight checks passed."
role_details=""
for project in "${PROJECTS[@]}"; do
project=$(echo "$project" | xargs)
[ -z "$project" ] && continue
roles=$(echo "$project_roles" | jq -r --arg p "$project" '.[] | select(.project == $p) | .roles | join(", ")')
role_details="${role_details}${project}: ${roles}; "
done
summary="Identity '$user_name' has $total_groups group(s). Project roles: ${role_details% ; }"
fi

# --- Write output ---
echo "$summary"

# =========================================================================
# 5. Write JSON output
# =========================================================================
result_json=$(jq -n \
--arg org "$AZURE_DEVOPS_ORG" \
--argjson identity "$identity_json" \
--argjson org_access "$org_access" \
--argjson projects "$project_results" \
--argjson memberships "$all_groups" \
--argjson project_roles "$project_roles" \
--argjson org_roles "$org_roles" \
--arg summary "$summary" \
--arg org "$AZURE_DEVOPS_ORG" \
'{
organization: $org,
identity: $identity,
org_level_access: $org_access,
project_access: $projects,
memberships: $memberships,
project_roles: $project_roles,
org_level_roles: $org_roles,
summary: $summary
}')

echo "$result_json" > "$OUTPUT_FILE"

echo ""
echo "=== Preflight Summary ==="
echo "$summary"
echo "Results saved to $OUTPUT_FILE"
2 changes: 1 addition & 1 deletion codebundles/azure-devops-project-health/runbook.robot
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ Suite Initialization
... bash_file=preflight-check.sh
... env=${preflight_env}
... secret__azure_devops_pat=${AZURE_DEVOPS_PAT}
... timeout_seconds=120
... timeout_seconds=180
... include_in_history=false

${preflight_json_raw}= RW.CLI.Run Cli
Expand Down