#!/usr/bin/env bash # ci/approval-policy.sh — the CI/CD pipeline is the approver, EXCEPT for changes that alter # what the pipeline verifies or the security posture it rests on. # # THE POLICY, and why it is shaped this way. # # Operator intent (2026-09-29): "CI/CD pipeline is the end-all-be-all for approval. If the CI # pipeline is not good enough to be trusted for this, then it is not good enough to do its # intended job and needs to be improved. Manual approvers should only be for architecturally or # security relevant changes -- which is essentially the CI pipeline itself and kyverno policies." # # So `required_approvals` on the branch drops to 0 and the GATE decides. That is a real transfer # of authority, and it has exactly one hole: **a change to the gate cannot be gated by the gate.** # A PR that weakens a check would be approved by the weakened check. The same applies to the # admission policies, the Flux wiring that decides what reconciles, the SOPS recipients, and the # RBAC. Those are the "architecturally or security relevant" set, and this stage is what still # requires a human for them. # # The list is data, not code: infra/gitea/approval-policy.json, with a stated reason per entry so # it stays reviewable. Changing that list is itself a sensitive change (infra/gitea/**), so the # policy cannot be widened without a human. # # FAIL-CLOSED, DELIBERATELY DIFFERENT FROM gitea-settings.sh. That stage skips when its token is # missing, because an unwired credential blocking every PR is a lock-out. Here the reverse is # true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a # BYPASS, not a gap. So: cannot determine -> BLOCK. # # THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears. # # The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a # pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an # APPROVED review from someone who is not its author" is a condition the sole administrator can # never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR # that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked. # # So an administrator may waive the review by COMMENTING on the PR: # # /approve-override # # Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must # hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the # gate runs, so an old comment from a since-demoted account is worth nothing. The directive must # start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override # earned at commit A does not cover commit B. Choosing the candidate comment is the job of the # pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage, # which fails closed. # # WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by # the gate" stops being structural and becomes advisory. That is the unavoidable consequence of # there being one human; the alternative is a repository its owner cannot change. What is bought # back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated # reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an # admin?" would have been less code and is exactly the fail-open shape this repo keeps finding. # # AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of # human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The # AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the # sensitive-path review on its own PR by posting the directive — and agents author most PRs in # this repo, so that is the common case, not an edge case. To narrow it without touching this # logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below # already requires membership whenever that key is present. # # EXIT CODES # 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and # either a non-author APPROVED review or a valid admin override is present # 1 sensitive paths touched with neither, OR the policy could not be evaluated set -euo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)" # shellcheck disable=SC1091 source "${CI_DIR}/lib.sh" log "Stage: approval policy (CI approves; humans approve changes to CI)" DECL="${REPO_ROOT}/infra/gitea/approval-policy.json" [ -f "$DECL" ] || die "approval policy declaration missing: ${DECL}" require_tool jq require_tool python3 # Not a PR -> nothing to gate. A push to main is already merged, and direct pushes are refused # by branch protection, so this is genuinely not-applicable rather than a skipped check. EVENT="${GITHUB_EVENT_NAME:-${EVENT_NAME:-}}" PR="${PR_NUMBER:-}" if [ "$EVENT" != "pull_request" ] || [ -z "$PR" ] || [ "$PR" = "null" ]; then ok "not a pull_request event (event='${EVENT:-none}') — approval policy N/A" exit 0 fi GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}" GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}" TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}" if [ -z "$TOKEN" ]; then err "no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews." err " BLOCKING. A skip here would let an unreviewed change to the gate itself merge." summary "- approval-policy: **BLOCKED — cannot evaluate** (no token; a skip here is a bypass)" exit 1 fi api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; } FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)" PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)" REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)" # PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review # (diff) comments ONLY, so an override posted in the normal comment box would never appear in it. # Verified against this Gitea (1.24.3) rather than assumed. # # This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits # behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted # `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that # turned "CI cannot read your override" into a message that said "you did not post one", and cost a # real debugging session on PR #38. It is handled explicitly, and loudly, further down: an # unreadable source can only cause a false BLOCK, never a false pass. COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)" for v in FILES_JSON PR_JSON REVIEWS_JSON; do jq -e . >/dev/null 2>&1 <<<"${!v}" || { err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming." summary "- approval-policy: **BLOCKED — Gitea unreadable**" exit 1; } done AUTHOR="$(jq -r '.user.login // empty' <<<"$PR_JSON")" [ -n "$AUTHOR" ] || { err "could not determine PR author — BLOCKING"; exit 1; } # NOTE: the PR file list goes in via an ENV VAR, not a second stdin redirect. `python3 - <&2 printf ' %s\n' $SENSITIVE >&2 APPROVER="$(jq -r --arg a "$AUTHOR" \ '[.[] | select(.state == "APPROVED") | select(.user.login != $a) | .user.login] | first // empty' \ <<<"$REVIEWS_JSON")" if [ -n "$APPROVER" ]; then ok "approved by '${APPROVER}' (author '${AUTHOR}') — sensitive change is human-reviewed." summary "- approval-policy: **green** (sensitive paths approved by \`${APPROVER}\`)" exit 0 fi # ───────────────────────────────────────────────────────────────────────────────────────── # No non-author approval exists. Before refusing, look for an administrator's explicit # override. See the header for why this hatch exists and what it costs. # ───────────────────────────────────────────────────────────────────────────────────────── OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")" DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")" HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")" if [ "$OVERRIDE_ENABLED" = "true" ]; then # The head commit's timestamp anchors staleness. Read it from GITEA, not from the local # tree: CI checks out a merge commit, whose date is not the head commit's date. HEAD_TIME="" if [ -n "$HEAD_SHA" ]; then HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)" HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)" fi if [ -z "$HEAD_TIME" ]; then err "could not read the head commit's timestamp for PR #${PR} — BLOCKING." err " Without it an override cannot be aged, and an unaged override is one that silently" err " covers commits nobody waived review on." summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)" exit 1 fi # Is the comments source actually readable? Say so plainly if not — a scope problem and "you # never posted an override" are indistinguishable from the outside, and conflating them is what # made PR #38 look like a code bug. if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)" warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}." warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted" warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an" warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type" warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository." summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override" COMMENTS_JSON='[]' fi # Pure, offline, unit-tested selection: which comment or review, if any, is a live override? OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \ HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \ python3 "${CI_DIR}/approval-override.py")" || { err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override." summary "- approval-policy: **BLOCKED — override parser failed**" exit 1; } if [ -n "$OVERRIDE_TSV" ]; then OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")" OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")" OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")" OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")" # AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git. PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)" OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)" if [ -z "$OV_PERM" ]; then err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be" err " read — BLOCKING. An override whose authority is unverified is not an override." summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**" exit 1 fi # Authorised when the LIVE permission is one of the declared ones and — only if an # `override_admins` allowlist exists at all — the account is on it. No allowlist means # permission alone, which is the operator's standing decision; adding the key narrows # this with no change to any code. AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c ' import json, os d = json.load(open(os.environ["DECL"])).get("override", {}) perms = d.get("authorized_permissions") or ["admin", "owner"] allow = d.get("override_admins") ok_perm = os.environ["OV_PERM"] in perms ok_list = True if allow is None else os.environ["OV_USER"] in allow print("yes" if (ok_perm and ok_list) else "no") ')" if [ "$AUTHORISED" = "yes" ]; then if [ "$OV_USER" = "$AUTHOR" ]; then warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the" warn " non-author review requirement on their own change. Permitted (permission" warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free." else warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'." fi warn " via: PR ${OV_SRC}" warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})" warn " reason: ${OV_WHY}" warn " review waived on: $(printf '%s ' $SENSITIVE)" ok "sensitive paths cleared by administrator override — this stage does not block." SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**" summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})" summary " - reason: ${OV_WHY}" summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)" exit 0 fi err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is" err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json." summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)" exit 1 fi fi err "This PR changes the pipeline or the security posture it rests on, and has NO approving" err " review from anyone other than its author ('${AUTHOR}')." err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot" err " honestly approve for itself. Get a human review, or move the change out of:" printf ' %s\n' $SENSITIVE >&2 err " The list lives in infra/gitea/approval-policy.json, with a reason per entry." if [ "$OVERRIDE_ENABLED" = "true" ]; then err " Or, if you hold admin/owner on this repository, post this on the PR:" err " ${DIRECTIVE} " err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read" err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)." err " The reason is mandatory, the directive must START a line, and it must be NEWER than" err " head commit ${HEAD_SHA:0:8} — push again and you must override again." fi summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**" exit 1