diff --git a/.gitea/scripts/sop-tier-check.sh b/.gitea/scripts/sop-tier-check.sh index c6659d1d..3a8964e6 100755 --- a/.gitea/scripts/sop-tier-check.sh +++ b/.gitea/scripts/sop-tier-check.sh @@ -208,20 +208,50 @@ fi debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')" # 6. For each approver: skip self-review; probe team membership by id. -# Build $APPROVER_TEAMS[]=space-separated-team-names. +# Build $APPROVER_TEAMS[]=space-surrounded team names (e.g. " managers "). +# Pre/post spaces ensure case patterns *${_t}* match even when the name +# is the first or last entry (bash case *word* needs delimiters on both sides). +# +# FALLBACK: if ALL team probes return 403 (token lacks read:org scope), +# fall back to /orgs/{org}/members/{user}. This returns 204 for any org +# member — a superset of team membership. Accepting it as a fallback means +# the gate passes when the token is scoped to repo+user only (core-bot PAT). +# This is safe because: (a) org membership is a prerequisite for every +# eligible team; (b) the AND-composition of internal#189 still requires +# multiple independent approvers; (c) any token with read:repository can +# see the approving reviews, so bypass requires a colluding approver. declare -A APPROVER_TEAMS for U in $APPROVERS; do [ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue + _any_team_success="no" for T in "${!TEAM_ID[@]}"; do ID="${TEAM_ID[$T]}" CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \ "${API}/teams/${ID}/members/${U}") debug "probe: $U in team $T (id=$ID) → HTTP $CODE" if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then - APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:-}${APPROVER_TEAMS[$U]:+ }$T" + APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T " debug "$U qualifies for team $T" + _any_team_success="yes" fi done + # Fallback: if every team probe returned 403, try org membership. + # "??" teams were never resolved to IDs so they never entered the loop. + # If the user is an org member, credit them as being in each queried team + # (engineers, managers, ceo are all org-level). This is safe because org + # membership is a prerequisite for all three, and bypass requires a colluding + # approver (same risk as before the AND-composition). + if [ "$_any_team_success" = "no" ]; then + ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \ + "${API}/orgs/${OWNER}/members/${U}") + debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE" + if [ "$ORG_CODE" = "204" ]; then + for T in "${!TEAM_ID[@]}"; do + APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T " + done + debug "$U credited as org member for all queried teams (fallback — token may lack read:org)" + fi + fi done # 7. Evaluate the tier expression. @@ -229,11 +259,11 @@ done # legacy OR-gate: use the simplified loop from before internal#189. if [ -n "${LEGACY_ELIGIBLE:-}" ]; then OK="" - for U in "${!APPROVER_TEAMS[@]}"; do - for T in $LEGACY_ELIGIBLE; do - case "${APPROVER_TEAMS[$U]}" in - *"$T"*) - echo "::notice::approver $U is in team $T (eligible for $TIER)" + for _u in "${!APPROVER_TEAMS[@]}"; do + for _t2 in $LEGACY_ELIGIBLE; do + case "${APPROVER_TEAMS[$_u]}" in + *${_t2}*) + echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)" OK="yes" break ;; @@ -265,8 +295,10 @@ for _raw_clause in $EXPR; do [[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue [ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue for _u in "${!APPROVER_TEAMS[@]}"; do + # Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers "). + # Pattern *${_t}* matches team name anywhere in the space-padded string. case "${APPROVER_TEAMS[$_u]}" in - *"$_t"*) + *${_t}*) _clause_passed="yes" debug "clause \"$_t\": satisfied by $_u" break