diff --git a/.gitea/workflows/gate.yml b/.gitea/workflows/gate.yml index 955ff90..127dc99 100644 --- a/.gitea/workflows/gate.yml +++ b/.gitea/workflows/gate.yml @@ -131,6 +131,10 @@ jobs: # merging. Green here means the token resolved and the live Gitea matched the # declaration in infra/gitea/expected-branch-protection.json. REQUIRE_GITEA_SETTINGS: "true" + # ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that + # PR's changed files and its reviews. Absent on a push event, where the stage is N/A. + GITHUB_EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: ./ci/gate.sh || [ "$?" -eq 3 ] # NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4 diff --git a/CLAUDE.md b/CLAUDE.md index e9b7e83..d1e3595 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,28 @@ There is no other path. Specifically: - **Do not `git push origin main`.** - **Do not `git push origin HEAD:main`.** Same thing wearing a hat. -- **Do not merge your own PR.** A human approves and clicks merge. +- **Do not merge your own PR.** See the approval model below — and either way, the merge is + someone else's action, never yours. + +### Who approves + +**The CI/CD pipeline is the approver.** A green gate is sufficient for an ordinary change; the +branch requires no human approval for one. That is a deliberate transfer of authority, on the +operator's stated principle: *if the pipeline is not good enough to be trusted for this, it is not +good enough to do its job and needs improving* — so a weak gate is a bug to fix, not a reason to +add a human. + +**One exception, enforced by `ci/approval-policy.sh`:** a change to the pipeline itself, the +admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an +approving review from someone who is not its author. The reason is narrow and structural — **a +change to the gate cannot be gated by the gate.** A PR that weakens a check would be approved by +the weakened check. Everything else merges on green. + +The path list is data, with a reason per entry: `infra/gitea/approval-policy.json`. It is itself a +sensitive path, so the policy cannot be widened or narrowed without a human. + +That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a +missing credential is a lock-out, here a skip would be a bypass. - **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`; anything applied by hand is drift that the next reconcile reverts or, worse, adopts. diff --git a/ci/Makefile b/ci/Makefile index 5706807..0603fec 100644 --- a/ci/Makefile +++ b/ci/Makefile @@ -32,6 +32,9 @@ render-golden: ## render==golden: canonical render reproduces committed apps/ (n l1: ## L1 rendering checks (no cluster; live now) @"$(CI_DIR)l1.sh" +approval-policy: ## CI is the approver; sensitive paths still need a human (needs PR_NUMBER + token) + @./approval-policy.sh + gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN) @./gitea-settings.sh diff --git a/ci/approval-policy.sh b/ci/approval-policy.sh new file mode 100755 index 0000000..8b1d978 --- /dev/null +++ b/ci/approval-policy.sh @@ -0,0 +1,117 @@ +#!/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 - <&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 diff --git a/ci/gate.sh b/ci/gate.sh index b1cf049..38fa360 100755 --- a/ci/gate.sh +++ b/ci/gate.sh @@ -112,6 +112,7 @@ run_stage lint "${CI_DIR}/lint.sh" run_stage l0 "${CI_DIR}/l0.sh" run_stage render-golden "${CI_DIR}/render-golden.sh" run_stage l1 "${CI_DIR}/l1.sh" +run_stage approval-policy "${CI_DIR}/approval-policy.sh" run_stage gitea-settings "${CI_DIR}/gitea-settings.sh" run_stage conformance "${CI_DIR}/conformance.sh" @@ -189,6 +190,20 @@ case "${OUTCOME[spec-immutability]}" in pass) ok "spec-immutability: green" ;; esac +# approval-policy: the branch requires NO human approvals (required_approvals: 0) -- the GATE +# is the approver, per operator intent. This stage is the single exception: a change to the +# pipeline itself, the admission policies, the Flux wiring, the SOPS recipients or the RBAC +# cannot honestly be approved BY the thing it changes, so those paths still need a human. +# +# It never SKIPS -- it exits 0 or 1 only, and blocks when it cannot evaluate. That is the +# opposite of gitea-settings' tolerance and it is deliberate: there, a missing credential is a +# lock-out; here, a missing credential would let an unreviewed change to the gate merge. +case "${OUTCOME[approval-policy]}" in + fail) err "approval-policy: a sensitive change lacks a non-author approval -> blocks merge"; BLOCK=1 ;; + skip) err "approval-policy: did not run -- it is fail-closed by design, so treat as a block"; BLOCK=1 ;; + pass) ok "approval-policy: green" ;; +esac + # gitea-settings: the ONLY check that can see Gitea's own database (branch protection + # runner registration). A FAIL is real drift and always blocks -- that is the whole point. # A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect diff --git a/infra/gitea/approval-policy.json b/infra/gitea/approval-policy.json new file mode 100644 index 0000000..4890c62 --- /dev/null +++ b/infra/gitea/approval-policy.json @@ -0,0 +1,28 @@ +{ + "_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.", + "_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.", + "_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.", + "sensitive_paths": [ + "ci/**", + ".gitea/**", + "conformance/**", + "clusters/**", + "bootstrap/**", + "infra/gitea/**", + "platform/kyverno/**", + "platform-config/kustomization.yaml", + ".sops.yaml", + "**/*.sops.yaml", + "**/rbac.yaml", + "CLAUDE.md" + ], + "_why_each": { + "ci/**, .gitea/**": "the pipeline itself -- the thing doing the approving", + "conformance/**": "the tests and the harness; changing them changes what green MEANS", + "clusters/**, bootstrap/**": "Flux wiring -- what reconciles, in what order, with what substitution", + "infra/gitea/**, platform-config/kustomization.yaml": "branch protection, the drift detector, and the list that decides which admission policies are actually applied", + "platform/kyverno/**": "the admission policy engine", + ".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority", + "CLAUDE.md": "the delivery contract agents follow" + } +}