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
1926OUTPUT_FILE=" preflight_results.json"
2027ORG_URL=" https://dev.azure.com/$AZURE_DEVOPS_ORG "
28+ VSSPS_URL=" https://vssps.dev.azure.com/$AZURE_DEVOPS_ORG "
2129
2230setup_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}' )
4580else
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
4887fi
4988
50- # --- Organization-level access ---
89+ # =========================================================================
90+ # 2. Enumerate group memberships via Graph API
91+ # =========================================================================
5192echo " "
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
59133else
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. "
62136fi
63137
64- # --- Per-project access checks ---
138+ # =========================================================================
139+ # 3. Per-project role summary
140+ # =========================================================================
65141echo " "
66- echo " === Testing per-project access ==="
67- project_results =' []'
142+ echo " === Per-Project Role Summary ==="
143+ project_roles =' []'
68144
69145IFS=' ,' read -ra PROJECTS <<< " $AZURE_DEVOPS_PROJECTS"
70146for 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)}]' )
138169done
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."
147203else
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% ; } "
149212fi
150213
151- # --- Write output ---
214+ echo " $summary "
215+
216+ # =========================================================================
217+ # 5. Write JSON output
218+ # =========================================================================
152219result_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
166235echo " $result_json " > " $OUTPUT_FILE "
167-
168- echo " "
169- echo " === Preflight Summary ==="
170- echo " $summary "
171236echo " Results saved to $OUTPUT_FILE "
0 commit comments