Skip to content

Commit fd6a030

Browse files
authored
Update Azure DevOps Health Monitoring Scripts for Timeout Adjustments and Documentation Enhancements (#627)
- Reduced the command timeout from 60 seconds to 30 seconds in the `_az_helpers.sh` script to improve responsiveness. - Enhanced the `preflight-check.sh` script documentation to clarify the purpose of the preflight checks and the output structure, including group memberships and role summaries. - Increased the timeout for the preflight check runbook from 120 seconds to 180 seconds to accommodate longer execution times for API calls.
1 parent d997b91 commit fd6a030

3 files changed

Lines changed: 178 additions & 113 deletions

File tree

codebundles/azure-devops-project-health/_az_helpers.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
: "${AZ_RETRY_COUNT:=3}"
66
: "${AZ_RETRY_INITIAL_WAIT:=5}"
7-
: "${AZ_CMD_TIMEOUT:=60}"
7+
: "${AZ_CMD_TIMEOUT:=30}"
88

99
# Run an az CLI command with retry and per-call timeout.
1010
# Usage: az_with_retry az pipelines list --output json
Lines changed: 176 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
#!/usr/bin/env bash
2-
# Preflight check: validates identity, API connectivity, and per-scope access
3-
# for each project before the main health checks run.
2+
# Preflight check: identifies the authenticated identity and enumerates
3+
# actual group memberships (roles) using the Azure DevOps REST API.
4+
#
5+
# Instead of "try an API and see if it works", this lists the concrete
6+
# roles the identity holds -- which is defensible and actionable when
7+
# troubleshooting permission issues.
48
#
59
# REQUIRED ENV VARS:
610
# AZURE_DEVOPS_ORG
711
# AZURE_DEVOPS_PROJECTS - comma-separated project names to validate
812
#
9-
# Outputs preflight_results.json with identity info and per-scope access results.
13+
# Outputs preflight_results.json with identity, group memberships, and
14+
# per-project role summary.
15+
16+
set -uo pipefail
1017

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

1926
OUTPUT_FILE="preflight_results.json"
2027
ORG_URL="https://dev.azure.com/$AZURE_DEVOPS_ORG"
28+
VSSPS_URL="https://vssps.dev.azure.com/$AZURE_DEVOPS_ORG"
2129

2230
setup_azure_auth
2331

24-
# --- Identity info ---
25-
echo "=== Identifying logged-in account ==="
26-
identity_json='{}'
32+
build_auth_header() {
33+
if [ "$AUTH_TYPE" = "pat" ]; then
34+
printf "Basic %s" "$(printf ':%s' "$AZURE_DEVOPS_EXT_PAT" | base64 -w0)"
35+
else
36+
local token
37+
token=$(az account get-access-token \
38+
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
39+
--query accessToken -o tsv 2>/dev/null || echo "")
40+
if [ -n "$token" ]; then
41+
printf "Bearer %s" "$token"
42+
fi
43+
fi
44+
}
45+
46+
AUTH_HEADER=$(build_auth_header)
47+
48+
api_get() {
49+
if [ -n "$AUTH_HEADER" ]; then
50+
curl -s --max-time 15 -H "Authorization: $AUTH_HEADER" "$1"
51+
else
52+
echo '{"error": "no auth header available"}'
53+
fi
54+
}
55+
56+
# =========================================================================
57+
# 1. Identify the authenticated user via _apis/connectionData
58+
# =========================================================================
59+
echo "=== Authenticated Identity ==="
60+
identity_json='{"name":"unknown","id":"unknown","auth_type":"'"$AUTH_TYPE"'","error":"not retrieved"}'
61+
subject_descriptor=""
62+
63+
conn_data=$(api_get "$ORG_URL/_apis/connectionData?api-version=7.1")
64+
65+
if echo "$conn_data" | jq -e '.authenticatedUser' &>/dev/null; then
66+
user_display=$(echo "$conn_data" | jq -r '.authenticatedUser.providerDisplayName // "unknown"')
67+
user_id=$(echo "$conn_data" | jq -r '.authenticatedUser.id // "unknown"')
68+
subject_descriptor=$(echo "$conn_data" | jq -r '.authenticatedUser.subjectDescriptor // empty')
2769

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

3474
identity_json=$(jq -n \
35-
--arg name "$identity_name" \
36-
--arg type "$identity_type" \
37-
--arg subscription "$subscription" \
38-
--arg tenant "$tenant_id" \
75+
--arg name "$user_display" \
76+
--arg id "$user_id" \
77+
--arg descriptor "${subject_descriptor:-}" \
3978
--arg auth_type "$AUTH_TYPE" \
40-
'{name: $name, type: $type, subscription: $subscription, tenant: $tenant, auth_type: $auth_type}')
41-
42-
echo " Identity: $identity_name ($identity_type)"
43-
echo " Auth: $AUTH_TYPE"
44-
echo " Tenant: $tenant_id"
79+
'{name: $name, id: $id, descriptor: $descriptor, auth_type: $auth_type}')
4580
else
46-
echo " WARNING: Could not retrieve account info"
47-
identity_json=$(jq -n --arg auth_type "$AUTH_TYPE" '{name: "unknown", type: "unknown", auth_type: $auth_type, error: "Could not retrieve account info"}')
81+
echo " ERROR: Could not retrieve identity via connectionData API"
82+
echo " Hint: Verify the PAT or service principal credentials are valid."
83+
if echo "$conn_data" | jq -e '.message' &>/dev/null; then
84+
api_msg=$(echo "$conn_data" | jq -r '.message' | head -c 300)
85+
echo " API message: $api_msg"
86+
fi
4887
fi
4988

50-
# --- Organization-level access ---
89+
# =========================================================================
90+
# 2. Enumerate group memberships via Graph API
91+
# =========================================================================
5192
echo ""
52-
echo "=== Testing organization-level access ==="
53-
org_access='{}'
54-
55-
echo -n " Agent pools: "
56-
if timeout 15 az pipelines pool list --org "$ORG_URL" --top 1 --output json &>/dev/null; then
57-
echo "OK"
58-
org_access=$(echo "$org_access" | jq '. + {agent_pools: "ok"}')
93+
echo "=== Group Memberships ==="
94+
all_groups='[]'
95+
96+
if [ -n "$subject_descriptor" ]; then
97+
membership_response=$(api_get "$VSSPS_URL/_apis/graph/memberships/$subject_descriptor?direction=up&api-version=7.1-preview.1")
98+
99+
if echo "$membership_response" | jq -e '.value' &>/dev/null; then
100+
member_count=$(echo "$membership_response" | jq '.value | length')
101+
echo " Resolving $member_count group membership(s)..."
102+
echo ""
103+
104+
while IFS= read -r desc; do
105+
[ -z "$desc" ] && continue
106+
group_info=$(api_get "$VSSPS_URL/_apis/graph/groups/$desc?api-version=7.1-preview.1")
107+
108+
if echo "$group_info" | jq -e '.principalName' &>/dev/null; then
109+
principal=$(echo "$group_info" | jq -r '.principalName // "unknown"')
110+
display=$(echo "$group_info" | jq -r '.displayName // "unknown"')
111+
scope_field=$(echo "$group_info" | jq -r '.domain // "unknown"')
112+
113+
echo " - $principal"
114+
115+
all_groups=$(echo "$all_groups" | jq \
116+
--arg p "$principal" \
117+
--arg d "$display" \
118+
--arg s "$scope_field" \
119+
'. += [{"principalName": $p, "displayName": $d, "scope": $s}]')
120+
fi
121+
done < <(echo "$membership_response" | jq -r '.value[].containerDescriptor // empty')
122+
123+
echo ""
124+
echo " Total: $(echo "$all_groups" | jq 'length') group(s)"
125+
else
126+
echo " WARNING: Could not list memberships via Graph API."
127+
echo " The PAT may lack the Graph (Read) or Member Entitlement Management (Read) scope."
128+
if echo "$membership_response" | jq -e '.message' &>/dev/null; then
129+
api_msg=$(echo "$membership_response" | jq -r '.message' | head -c 300)
130+
echo " API message: $api_msg"
131+
fi
132+
fi
59133
else
60-
echo "DENIED/FAILED"
61-
org_access=$(echo "$org_access" | jq '. + {agent_pools: "denied_or_failed"}')
134+
echo " SKIPPED: No identity descriptor available -- cannot enumerate memberships."
135+
echo " This typically means the connectionData call above failed."
62136
fi
63137

64-
# --- Per-project access checks ---
138+
# =========================================================================
139+
# 3. Per-project role summary
140+
# =========================================================================
65141
echo ""
66-
echo "=== Testing per-project access ==="
67-
project_results='[]'
142+
echo "=== Per-Project Role Summary ==="
143+
project_roles='[]'
68144

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

74150
echo " Project: $project"
75-
proj_result=$(jq -n --arg name "$project" '{project: $name}')
76-
77-
# Project access
78-
echo -n " project show: "
79-
if timeout 15 az devops project show --project "$project" --org "$ORG_URL" --output json &>/dev/null; then
80-
echo "OK"
81-
proj_result=$(echo "$proj_result" | jq '. + {project_access: "ok"}')
82-
else
83-
echo "DENIED/FAILED"
84-
proj_result=$(echo "$proj_result" | jq '. + {project_access: "denied_or_failed"}')
85-
fi
86-
87-
# Pipelines read
88-
echo -n " pipelines list: "
89-
if timeout 15 az pipelines list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
90-
echo "OK"
91-
proj_result=$(echo "$proj_result" | jq '. + {pipelines: "ok"}')
92-
else
93-
echo "DENIED/FAILED"
94-
proj_result=$(echo "$proj_result" | jq '. + {pipelines: "denied_or_failed"}')
95-
fi
96151

97-
# Pipeline runs read
98-
echo -n " pipeline runs list: "
99-
if timeout 15 az pipelines runs list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
100-
echo "OK"
101-
proj_result=$(echo "$proj_result" | jq '. + {pipeline_runs: "ok"}')
102-
else
103-
echo "DENIED/FAILED"
104-
proj_result=$(echo "$proj_result" | jq '. + {pipeline_runs: "denied_or_failed"}')
105-
fi
106-
107-
# Repos read
108-
echo -n " repos list: "
109-
if timeout 15 az repos list --project "$project" --org "$ORG_URL" --top 1 --output json &>/dev/null; then
110-
echo "OK"
111-
proj_result=$(echo "$proj_result" | jq '. + {repos: "ok"}')
112-
else
113-
echo "DENIED/FAILED"
114-
proj_result=$(echo "$proj_result" | jq '. + {repos: "denied_or_failed"}')
115-
fi
152+
proj_prefix="[$project]\\"
153+
proj_groups=$(echo "$all_groups" | jq --arg p "$proj_prefix" \
154+
'[.[] | select(.principalName | startswith($p)) | .displayName]')
155+
count=$(echo "$proj_groups" | jq 'length')
116156

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

127-
# Repo policies read
128-
echo -n " repo policies: "
129-
if timeout 15 az repos policy list --project "$project" --org "$ORG_URL" --output json &>/dev/null; then
130-
echo "OK"
131-
proj_result=$(echo "$proj_result" | jq '. + {repo_policies: "ok"}')
132-
else
133-
echo "DENIED/FAILED"
134-
proj_result=$(echo "$proj_result" | jq '. + {repo_policies: "denied_or_failed"}')
135-
fi
136-
137-
project_results=$(echo "$project_results" | jq --argjson proj "$proj_result" '. += [$proj]')
165+
project_roles=$(echo "$project_roles" | jq \
166+
--arg proj "$project" \
167+
--argjson groups "$proj_groups" \
168+
'. += [{"project": $proj, "roles": $groups, "role_count": ($groups | length)}]')
138169
done
139170

140-
# --- Build summary ---
141-
denied_count=$(echo "$project_results" | jq '[.[] | to_entries[] | select(.value == "denied_or_failed" and .key != "project")] | length')
142-
org_denied=$(echo "$org_access" | jq '[to_entries[] | select(.value == "denied_or_failed")] | length')
143-
total_denied=$((denied_count + org_denied))
171+
# Org-level roles
172+
echo ""
173+
echo " Organization-level roles:"
174+
org_prefix="[$AZURE_DEVOPS_ORG]\\"
175+
org_roles=$(echo "$all_groups" | jq --arg o "$org_prefix" \
176+
'[.[] | select(.principalName | startswith($o)) | .displayName]')
177+
org_count=$(echo "$org_roles" | jq 'length')
178+
179+
if [ "$org_count" -gt 0 ]; then
180+
echo "$org_roles" | jq -r '.[] | " Role: " + .'
181+
else
182+
echo " (none found)"
183+
fi
184+
185+
# =========================================================================
186+
# 4. Build summary
187+
# =========================================================================
188+
echo ""
189+
echo "=== Preflight Summary ==="
144190

145-
if [ "$total_denied" -gt 0 ]; then
146-
summary="WARNING: $total_denied API scope(s) returned denied or failed. Some health checks may produce incomplete results."
191+
total_groups=$(echo "$all_groups" | jq 'length')
192+
projects_with_roles=$(echo "$project_roles" | jq '[.[] | select(.role_count > 0)] | length')
193+
total_projects=$(echo "$project_roles" | jq 'length')
194+
user_name=$(echo "$identity_json" | jq -r '.name')
195+
196+
if [ "$total_groups" -eq 0 ] && [ -n "$subject_descriptor" ]; then
197+
summary="WARNING: Identity '$user_name' authenticated successfully but has 0 group memberships. Check Graph API scope on the PAT."
198+
elif [ "$total_groups" -eq 0 ]; then
199+
summary="ERROR: Could not identify the authenticated user or enumerate permissions. Check credentials."
200+
elif [ "$projects_with_roles" -lt "$total_projects" ]; then
201+
missing=$(echo "$project_roles" | jq -r '[.[] | select(.role_count == 0) | .project] | join(", ")')
202+
summary="WARNING: Identity '$user_name' has $total_groups group(s) but no project-level roles in: $missing. These projects may produce incomplete results."
147203
else
148-
summary="All API scopes accessible. Preflight checks passed."
204+
role_details=""
205+
for project in "${PROJECTS[@]}"; do
206+
project=$(echo "$project" | xargs)
207+
[ -z "$project" ] && continue
208+
roles=$(echo "$project_roles" | jq -r --arg p "$project" '.[] | select(.project == $p) | .roles | join(", ")')
209+
role_details="${role_details}${project}: ${roles}; "
210+
done
211+
summary="Identity '$user_name' has $total_groups group(s). Project roles: ${role_details% ; }"
149212
fi
150213

151-
# --- Write output ---
214+
echo "$summary"
215+
216+
# =========================================================================
217+
# 5. Write JSON output
218+
# =========================================================================
152219
result_json=$(jq -n \
220+
--arg org "$AZURE_DEVOPS_ORG" \
153221
--argjson identity "$identity_json" \
154-
--argjson org_access "$org_access" \
155-
--argjson projects "$project_results" \
222+
--argjson memberships "$all_groups" \
223+
--argjson project_roles "$project_roles" \
224+
--argjson org_roles "$org_roles" \
156225
--arg summary "$summary" \
157-
--arg org "$AZURE_DEVOPS_ORG" \
158226
'{
159227
organization: $org,
160228
identity: $identity,
161-
org_level_access: $org_access,
162-
project_access: $projects,
229+
memberships: $memberships,
230+
project_roles: $project_roles,
231+
org_level_roles: $org_roles,
163232
summary: $summary
164233
}')
165234

166235
echo "$result_json" > "$OUTPUT_FILE"
167-
168-
echo ""
169-
echo "=== Preflight Summary ==="
170-
echo "$summary"
171236
echo "Results saved to $OUTPUT_FILE"

codebundles/azure-devops-project-health/runbook.robot

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ Suite Initialization
601601
... bash_file=preflight-check.sh
602602
... env=${preflight_env}
603603
... secret__azure_devops_pat=${AZURE_DEVOPS_PAT}
604-
... timeout_seconds=120
604+
... timeout_seconds=180
605605
... include_in_history=false
606606

607607
${preflight_json_raw}= RW.CLI.Run Cli

0 commit comments

Comments
 (0)