Implements the operator intent: "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." Adds ci/approval-policy.sh as a gate stage. A green gate becomes sufficient for an ordinary change; the one exception is structural rather than a matter of taste: 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 holds for the admission policies, the Flux wiring that decides what reconciles, the SOPS recipients and the RBAC. Those paths -- and only those -- still require an APPROVED review from someone other than the author. The list is data with a reason per entry (infra/gitea/approval-policy.json) and is itself a sensitive path, so the policy cannot be widened or narrowed without a human. FAIL-CLOSED, and deliberately the opposite of gitea-settings. There, a missing credential must not block every PR -- that is a lock-out. Here, a missing credential would let an unreviewed change to the gate merge, so "cannot evaluate" is a BLOCK. Verified against real PRs rather than fixtures: PR #16 (sensitive paths, approved by jakman85, author gitea-admin) -> PASS PR #6 (.gitea/workflows/gate.yml, ZERO reviews) -> BLOCK, naming the file push event (not a PR) -> N/A, exit 0 bogus token -> BLOCK, not skip Also caught before shipping: the first draft passed the PR file list with a second stdin redirect after a heredoc, which silently wins and leaves the script body unread. Running it found that; reading it would not have. required_approvals is NOT changed here. Flipping it live must happen together with the declaration in expected-branch-protection.json, or gitea-settings correctly fails on the drift -- that is a separate, deliberate change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
118 lines
5.7 KiB
Bash
Executable File
118 lines
5.7 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.
|
|
#
|
|
# EXIT CODES
|
|
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
|
|
# non-author APPROVED review is present
|
|
# 1 sensitive paths touched without such a review, 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)"
|
|
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
|
|
|
|
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."
|
|
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
|
|
exit 1
|