The override did not work on its first real test, and the reason was invisible from the outside. Run 203 on PR #38: a well-formed `/approve-override` posted by jakman85 in the PR comment box, 29 minutes newer than the head commit, and the stage still blocked with: approval-override: COMMENTS_JSON is not a list SETTINGS_API_TOKEN is minted [read:repository, read:admin], and Gitea puts /issues/{n}/comments behind the *issue* scope — so Gitea answered with an error OBJECT, `jq -e .` accepted it as valid JSON, and the stage fell through to 'no approving review'. A permission failure wearing the costume of a missing override. /pulls/{n}/files and /pulls/{n}/reviews are under read:repository, which is why every other part of the same stage worked. 1. The diagnosis no longer lies. COMMENTS_JSON leaves the must-parse-or-block loop and is checked for being an ARRAY; when it is not, the stage prints Gitea's own .message, names the missing scope and says where to post instead. An unreadable source can only cause a false BLOCK, never a false pass, so a loud warning is the honest behaviour and blocking was not. 2. An override is accepted from a PR REVIEW body as well as a comment, so the hatch is not hostage to one API scope — a review of type 'Comment' works on the token CI has today. The gate names which source it used; PENDING (unsubmitted, author-only) reviews are dropped. 3. set-ci-token.sh requests read:issue, making the ordinary comment box work — on a RE-RUN only, since an existing token's scopes cannot be widened. ci/tests/approval-override.test.sh: 23 -> 38 assertions, covering the reviews source, PENDING reviews, an error object from the comments API, 'an unreadable comments source must not suppress a valid review override', latest-wins across both sources, and that the diagnostic quotes Gitea's message. Also measured and recorded: run 203 shows gitea-settings PASS after the operator ran the live PATCH, with block_admin_merge_override still true — the narrow change held, the approval count moved and the gate's teeth did not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
275 lines
16 KiB
Bash
Executable File
275 lines
16 KiB
Bash
Executable File
#!/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 <reason>
|
|
#
|
|
# 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 - <<EOF`
|
|
# already consumes stdin for the script; adding `<<<"$json"` silently wins and the script body is
|
|
# never read. Caught before shipping, by running it.
|
|
SENSITIVE="$(FILES_JSON="$FILES_JSON" DECL="$DECL" python3 -c '
|
|
import json, os, fnmatch
|
|
decl = json.load(open(os.environ["DECL"]))
|
|
files = [f.get("filename", "") for f in json.loads(os.environ["FILES_JSON"])]
|
|
pats = decl["sensitive_paths"]
|
|
hits = sorted({f for f in files for p in pats
|
|
if fnmatch.fnmatch(f, p) or (p.endswith("/**") and f.startswith(p[:-2]))})
|
|
print("\n".join(hits))
|
|
')"
|
|
|
|
if [ -z "$SENSITIVE" ]; then
|
|
ok "no architecturally/security-relevant paths touched — the GATE is the approver."
|
|
summary "- approval-policy: **green** (CI approves; no sensitive paths touched)"
|
|
exit 0
|
|
fi
|
|
|
|
echo " sensitive paths touched:" >&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} <reason>"
|
|
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
|