ci/woodpecker/push/publish Pipeline failed
Co-authored-by: veronica <[email protected]>
344 lines
14 KiB
Bash
Executable File
344 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
|
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
|
|
# Gitea repository via an org team (default: fleet-reviewers).
|
|
#
|
|
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
|
|
#
|
|
# The team carries `permission: read` with per-unit overrides
|
|
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
|
|
# read code and write issues/PR reviews, but cannot push. The grant is
|
|
# idempotent — the team is looked up before it is created, and member/repo
|
|
# additions are PUTs.
|
|
#
|
|
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
|
|
# Gitea computes a review's `official` flag at SUBMISSION time, from write
|
|
# permission on the repo or from membership in the protected branch's
|
|
# approvals whitelist (disabled by default). A team granted through this
|
|
# script has read permission on code, so under branch protection with
|
|
# required_approvals the reviewer's approval shows but does NOT count toward
|
|
# the required total — the merge still fails with "not enough approvals".
|
|
# Enabling the approvals whitelist and adding this team to it is review
|
|
# policy (who counts as an official approver), an operator decision made in
|
|
# the repo's branch-protection settings, deliberately NOT automated here.
|
|
# Because `official` is fixed at submission, whitelisting after the fact
|
|
# requires the review to be re-submitted before it counts.
|
|
#
|
|
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
|
|
# GitHub review access is granted through collaborator/team facilities that
|
|
# have no equivalent to Gitea's org-team unit map.
|
|
#
|
|
# Identity: the acting credential resolves exactly as in issue-comment.sh —
|
|
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
|
|
# remote host (fail closed, never downgrade to the host default identity);
|
|
# otherwise the per-seat identity ladder in detect-platform.sh applies
|
|
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
|
|
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
|
|
# an HTTP 403 from any step is reported as "org admin required on <org>",
|
|
# never as a silent partial grant.
|
|
#
|
|
# Verification is fail-closed: after the member and repo PUTs, the script
|
|
# GETs the single resources back (GET /teams/{id}/members/{user} and
|
|
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
|
|
# both confirm the grant. A PUT that returns success without persisting
|
|
# (the #865 defect class: an exit code is not evidence of a durable write)
|
|
# therefore fails the run instead of reporting a grant that does not exist.
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
usage() {
|
|
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -u, --user Gitea username to grant reviewer access (required)"
|
|
echo " -r, --repo Target repository as <owner>/<repo>; defaults to the"
|
|
echo " current repository's origin. The owner must be an"
|
|
echo " organization."
|
|
echo " -t, --team Org team to use/create (default: fleet-reviewers)"
|
|
echo " -h, --help Show this help"
|
|
echo ""
|
|
echo "Environment:"
|
|
echo " GITEA_LOGIN Override the acting identity with a named tea login"
|
|
echo " (must resolve for the remote host; fails closed)."
|
|
echo ""
|
|
echo "Grants: code read + issues/pulls write via an org team. Gitea only."
|
|
echo ""
|
|
echo "LIMITATION: under branch protection with required approvals, reviews"
|
|
echo "from a read-permission team are official=false and do not count"
|
|
echo "toward the required total. Making them count means enabling the"
|
|
echo "protected branch's approvals whitelist and adding the team — an"
|
|
echo "operator review-policy decision this script does not automate. The"
|
|
echo "official flag is computed at review submission, so a review made"
|
|
echo "before whitelisting must be re-submitted afterwards."
|
|
}
|
|
|
|
REVIEWER=""
|
|
REPO_OVERRIDE=""
|
|
TEAM="fleet-reviewers"
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-u|--user)
|
|
REVIEWER="$2"
|
|
shift 2
|
|
;;
|
|
-r|--repo)
|
|
REPO_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-t|--team)
|
|
TEAM="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$REVIEWER" ]]; then
|
|
echo "Error: reviewer username is required (-u)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
|
|
# values safe to interpolate into API paths without URL-encoding.
|
|
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
|
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
|
|
echo "Error: invalid reviewer username '$REVIEWER'" >&2
|
|
exit 1
|
|
fi
|
|
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
|
|
echo "Error: invalid team name '$TEAM'" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
|
echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
|
|
exit 1
|
|
fi
|
|
|
|
detect_platform >/dev/null
|
|
|
|
if [[ "$PLATFORM" != "gitea" ]]; then
|
|
echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
|
|
echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
|
|
exit 1
|
|
fi
|
|
|
|
HOST=$(get_remote_host) || {
|
|
echo "Error: could not resolve the remote host from origin" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
|
|
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
|
|
# never silently downgraded to the host default identity.
|
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
|
|
echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
|
|
exit 1
|
|
}
|
|
else
|
|
GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
|
|
echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
|
|
CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
|
|
echo "Error: configured Gitea URL not found for host '$HOST'" >&2
|
|
exit 1
|
|
}
|
|
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"
|
|
|
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
|
REPO_SLUG="$REPO_OVERRIDE"
|
|
else
|
|
REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
|
|
echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
|
|
exit 1
|
|
}
|
|
fi
|
|
ORG="${REPO_SLUG%%/*}"
|
|
REPO_NAME="${REPO_SLUG#*/}"
|
|
|
|
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
|
|
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$RESPONSE_FILE"
|
|
echo "Error: could not stage Gitea credential for reviewer grant" >&2
|
|
exit 1
|
|
}
|
|
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
|
|
|
|
# gitea_api <step> <method> <path> [json-payload]
|
|
# Runs one API call with the staged credential (token never in argv). Sets
|
|
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
|
|
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
|
|
# cannot manage org teams, and the run must stop rather than continue into a
|
|
# partial grant.
|
|
gitea_api() {
|
|
local step="$1" method="$2" path="$3" payload="${4:-}"
|
|
local -a payload_args=()
|
|
if [[ -n "$payload" ]]; then
|
|
payload_args=(-H 'Content-Type: application/json' -d "$payload")
|
|
fi
|
|
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
|
-X "$method" \
|
|
--config "$AUTH_CONFIG" \
|
|
"${payload_args[@]}" \
|
|
"$GITEA_API_ROOT$path"); then
|
|
echo "Error: Gitea transport failed during $step" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$GITEA_API_STATUS" == "403" ]]; then
|
|
echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# json_field <file> <key> — print a top-level scalar field or fail.
|
|
json_field() {
|
|
python3 - "$1" "$2" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
data = json.load(response)
|
|
value = data.get(sys.argv[2]) if isinstance(data, dict) else None
|
|
if value is None or isinstance(value, (dict, list, bool)):
|
|
raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
|
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(value)
|
|
PY
|
|
}
|
|
|
|
# 1. The owner must be an organization: teams are an org facility, and a
|
|
# user-owned repo would fail later with a misleading team error.
|
|
gitea_api "organization check" GET "/orgs/$ORG"
|
|
if [[ "$GITEA_API_STATUS" == "404" ]]; then
|
|
echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
|
echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
|
|
# The search endpoint substring-matches, so the exact-name filter is done
|
|
# on the response, not trusted to the query.
|
|
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
|
|
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
|
echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
|
exit 1
|
|
fi
|
|
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
wanted = os.environ["TEAM_NAME"]
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
result = json.load(response)
|
|
teams = result.get("data") if isinstance(result, dict) else None
|
|
if not isinstance(teams, list):
|
|
raise ValueError("team search response carried no data list")
|
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
print(f"Error: unusable team search response: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
for team in teams:
|
|
if isinstance(team, dict) and team.get("name") == wanted:
|
|
team_id = team.get("id")
|
|
if not isinstance(team_id, int) or team_id <= 0:
|
|
print("Error: matched team carried no positive id", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(team_id)
|
|
raise SystemExit(0)
|
|
print("")
|
|
PY
|
|
)
|
|
|
|
if [[ -z "$TEAM_ID" ]]; then
|
|
CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
|
|
import json
|
|
import os
|
|
|
|
print(json.dumps({
|
|
"name": os.environ["TEAM_NAME"],
|
|
"description": "review seats: code read + issues/pulls write",
|
|
"permission": "read",
|
|
"includes_all_repositories": False,
|
|
"can_create_org_repo": False,
|
|
"units_map": {
|
|
"repo.code": "read",
|
|
"repo.issues": "write",
|
|
"repo.pulls": "write",
|
|
},
|
|
}))
|
|
')
|
|
gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
|
|
if [[ "$GITEA_API_STATUS" != "201" ]]; then
|
|
echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
|
exit 1
|
|
fi
|
|
TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
|
|
echo "Error: team create returned no usable team id" >&2
|
|
exit 1
|
|
}
|
|
echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
|
else
|
|
echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
|
fi
|
|
|
|
# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
|
|
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
|
|
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
|
echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
|
exit 1
|
|
fi
|
|
|
|
gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
|
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
|
echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
|
|
# grant persisted. GET the single resources back and require both.
|
|
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
|
|
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
|
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
|
exit 1
|
|
fi
|
|
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
|
|
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
|
|
echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
|
|
exit 1
|
|
fi
|
|
|
|
gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
|
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
|
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
|
exit 1
|
|
fi
|
|
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
|
|
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
|
|
echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
|
|
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
|