diff --git a/codebundles/azure-devops-project-health/_az_helpers.sh b/codebundles/azure-devops-project-health/_az_helpers.sh index 9b861461..01039774 100755 --- a/codebundles/azure-devops-project-health/_az_helpers.sh +++ b/codebundles/azure-devops-project-health/_az_helpers.sh @@ -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 diff --git a/codebundles/azure-devops-project-health/preflight-check.sh b/codebundles/azure-devops-project-health/preflight-check.sh index a423a222..d0f65513 100755 --- a/codebundles/azure-devops-project-health/preflight-check.sh +++ b/codebundles/azure-devops-project-health/preflight-check.sh @@ -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}" @@ -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" diff --git a/codebundles/azure-devops-project-health/runbook.robot b/codebundles/azure-devops-project-health/runbook.robot index 732e88a1..b3c768f3 100755 --- a/codebundles/azure-devops-project-health/runbook.robot +++ b/codebundles/azure-devops-project-health/runbook.robot @@ -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