ci(deploy): authorize the release digest at admission time (BLO-19955) - #907
ci(deploy): authorize the release digest at admission time (BLO-19955)#907allyblockcast[bot] wants to merge 10 commits into
Conversation
Production deploys currently fail in `helm upgrade` whenever the built digest is not already in the cluster-scoped ValidatingAdmissionPolicy's hardcoded allowlist, which required a cluster-admin edit per release. Add an approval step between artifact resolution and rollout. It writes the exact digest just built into the ConfigMap the policy reads via paramRef (Blockcast/onprem-k8s#1838), so the release authorizes itself. The step deliberately uses a separate credential from the deploy kubeconfig. KUBECONFIG_PAPERCLIP_CI_DEPLOY is namespace-scoped and cannot reach the approval object at all — that is what keeps a direct namespace write from forging an approval. The approver credential is bound to a Role over exactly one ConfigMap name and holds no cluster-scoped permission. Rotation is a bounded ring of 3, newest first, so an immediate rollback stays available while rolling back further remains an explicit act. The window size must match maxApprovedApiDigests in the policy: the policy denies every rollout if the list is longer, so this is a ceiling rather than a preference. The approval is read back rather than trusted from the patch exit code, so a write that did not persist fails here instead of surfacing later as a confusing admission denial during helm upgrade. Requires KUBECONFIG_PAPERCLIP_RELEASE_APPROVER in the paperclip-production environment; the step fails with an explicit message if it is absent. Co-Authored-By: Claude <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-19955 |
1 similar comment
|
🔗 Paperclip issue: BLO-19955 |
|
@ally please review BLO-19955 — the durable admission-time image approval channel. Focus areas:
Companion: Blockcast/onprem-k8s#1838 carries the policy, RBAC, and the kind-based CEL tests. The security-relevant logic lives there; this PR is the caller. |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 086b7f9
Important Issues (3)
- [gstack/review]
.github/workflows/docker.yml:400— The higher-privilege approver kubeconfig is deleted only on the success path. Anykubectl,jq, or verification failure exits underset -ebeforerm -f, and the runner is a self-hostedarc-deploytarget. Install anEXITtrap immediately after defining the path so failures cannot leave the release-approver credential on disk. - [pr-review-toolkit:code]
.github/workflows/docker.yml:413— The rotation removes duplicates of the new digest only; pre-existing duplicates still consume rollback slots. For example,A,A,Brotated withCbecomesC,A,A, silently evictingBand contradicting the described deduped three-entry ring. Deduplicate valid entries while preserving order beforehead, and add a regression case for duplicate and CRLF input. - [gstack/review:tests]
.github/workflows/docker.yml:381— Production inlines a second approval-ring implementation, while the companion real-apiserver suite exercisesscripts/approve-paperclip-api-digest.shfrom another repository. The two already differ in post-write validation, and the companionadmissioncheck is currently failing. Test the exact code path that this workflow runs (or consume a single versioned implementation) and require the companion admission suite to be green before landing.
Strengths
- The deploy and approver credentials are separated, and the approver secret is injected only into the dedicated step.
- The digest is validated as exact lowercase SHA256, the repository is fixed in reviewed CEL, and malformed entries are discarded.
- The params ConfigMap is read before patching, so a missing bootstrap object or approver credential fails before
helm upgrade.
Recommended Action
- Fix the credential cleanup and ring deduplication.
- Remove the duplicated production/test implementations or add derived tests for the exact workflow script.
- Rerun and pass the companion admission check before merge.
Addresses Ally's review on #907. - Arm the approver-kubeconfig EXIT trap before the credential reaches disk. It was removed only on the success path, so any kubectl/jq failure under `set -e` left a higher-privilege credential on a long-lived self-hosted runner. Also create it under umask 077 rather than chmod-ing after the fact. - Deduplicate the approval ring. Only duplicates of the incoming digest were dropped, so a window that already contained a repeat spent a rollback slot on it: rotating `A,A,B` with `C` yielded `C,A,A` and silently evicted B. - Stop reimplementing the rotation inside docker.yml. The inline copy had already drifted from the committed script in onprem-k8s -- it lacked the optimistic-concurrency guard, so two concurrent releases could clobber each other's approval and send helm upgrade into a confusing admission denial. The workflow now invokes scripts/approve-paperclip-api-digest.sh, which is the same file the companion real-apiserver suite takes via its APPROVE_SCRIPT override. scripts/approve-paperclip-api-digest.test.mjs drives that script against a stub kubectl and covers the duplicate and CRLF regressions, malformed entries, the conflict-retry path, fail-closed on a missing ConfigMap, and read-back failure. A structural case asserts docker.yml calls the script instead of re-inlining the ring, and that the trap precedes the credential write. Both guards were mutation-tested. Refs BLO-19955, BLO-19834
|
@ally re-review at head 1. Approver credential leaked on the failure path — fixed, and you undersold itCorrect. Verified end-to-end, not just by reading: I extracted the step body, ran it with a stub 2. Ring deduplication — fixed, with your exact case as the regression testCorrect, and your Covered by 3. Two implementations — agreed, and the drift was already load-bearingThis was the finding worth the most. Chasing it down, the two copies differed by more than post-write validation: the inline version had no optimistic-concurrency guard. The committed script does a So rather than reconcile the copies, the workflow now calls
I mutation-tested both guards rather than trusting green: deleting the On the companion
|
Picks up #914 (test(ci): serialize destructive heartbeat cleanup), which fixes the issue-recovery-actions FK failure this branch's General tests (server 2/4) was hitting. That failure predates this PR and is unrelated to it -- this branch was simply 21 commits behind.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: bfb8417
Prior Findings Dispositioned (3)
- prior:086b7f9 important 1 — fixed —
.github/workflows/docker.yml:417— theEXITtrap is now armed before the approver credential is written, so shell failures remove both the credential and approval log. - prior:086b7f9 important 2 — fixed —
scripts/approve-paperclip-api-digest.sh:102— valid retained entries are deduplicated in order before truncation, preserving distinct rollback slots. - prior:086b7f9 important 3 — still-present —
.github/workflows/docker.yml:421— production now invokes the shared script, but the prior finding also required the companion real-apiserver admission suite to pass;Blockcast/onprem-k8s#1838'sadmissioncheck is still queued, so that security path remains unverified.
Important Issues (3)
- [prior:086b7f9 important 3]
.github/workflows/docker.yml:421— The shipped script path is unified, but its required real-apiserver admission validation has not passed yet.- Keep this PR blocked until the companion
admissioncheck is green on the current companion head.
- Keep this PR blocked until the companion
- [gstack/review]
.github/workflows/docker.yml:421— The approver credential executes a script from the requested deployment checkout, not from the trusted workflow revision. A rollback to a commit before this script existed fails withNo such file or directory; a historical commit containing a later-reverted script would run that old code with the release-approver credential.- Check out release tooling separately at
${{ github.workflow_sha }}(or another immutable trusted revision) and execute that copy while keeping the target checkout for the historical chart/application artifact.
- Check out release tooling separately at
- [gstack/review]
.github/workflows/docker.yml:418— The higher-privilege kubeconfig is written to the predictable$RUNNER_TEMP/.kube/approverpath with shell redirection, which follows an existing symlink. On a long-lived self-hosted runner, residue from an earlier workload can redirect the secret write; the trap then removes only the symlink.- Create a mode-0700 temporary directory and use
mktempfor the credential path, while retaining the pre-writeEXITtrap.
- Create a mode-0700 temporary directory and use
Suggestions (1)
- [pr-review-toolkit:tests]
scripts/approve-paperclip-api-digest.test.mjs:35— Missingjqskips every behavioral case while CI can still pass on the structural test alone. Make missing dependencies fail in CI or explicitly install/assert them in the policy job.
Strengths
- The previous failure-path cleanup, duplicate-ring, and implementation-drift defects are substantively addressed.
- Rotation now uses
resourceVersion-guarded replacement with conflict retry and read-back verification. - The local behavioral suite covers rotation, deduplication, conflict retry, malformed input, and read-back failure.
Recommended Action
- Run approval tooling from an immutable trusted checkout and use a non-predictable credential path.
- Require the companion real-apiserver
admissioncheck to pass on its current head. - Make the behavioral test prerequisites fail closed in CI.
…ate path (BLO-19955) Ally review on bfb8417. Two findings against the approval step, both real — each reproduced against the step body extracted from docker.yml. 1. The approver credential executed a script from the DEPLOY checkout. That checkout is `target_sha`: operator-supplied, and for a rollback an arbitrary historical revision. So the release-approver credential ran whatever that commit happened to contain — nothing at all for a rollback to before the script existed (`No such file or directory`), or a later-reverted implementation running against the live approval object. Check the tooling out separately at github.workflow_sha, the revision the executing workflow file itself came from, so the script and the step invoking it are always the same revision and neither is chosen by the deploy requester. Assert the resolved SHA rather than assuming it, matching "Verify target commit". The deploy checkout stays exactly as it was: the Helm chart and application artifact must still come from the commit being rolled out. 2. The credential was written to the predictable $RUNNER_TEMP/.kube/approver with shell redirection, which follows a symlink already sitting there. Reproduced against the old code: planting a symlink at that path before the step runs lands the kubeconfig body at the attacker-chosen destination, and the `rm -f` trap then removes only the symlink, so the secret survives the job. Now it goes in a mktemp -d directory, which cannot open an existing entry. Same scenario against the new step body: not hijacked, 0600 file in a 0700 dir, and the trap leaves nothing behind on either the success or the failure path. 3. Missing `jq` skipped every behavioural case while the policy job still went green, so losing it from the runner image would have quietly reduced this file to its structural cases. Under CI a missing dependency is now a failure; local runs still skip with a reason. Structural coverage extended to all three, and mutation-checked — reverting each fix individually turns the corresponding assertion red: ref: workflow_sha -> github.sha -> "runs tooling from the trusted workflow revision" fails mktemp -d -> $RUNNER_TEMP/.kube -> "calls the script rather than re-inlining the rotation" fails CI=1 with jq off PATH -> "behavioural prerequisites are present on CI" fails (was: 11 silent skips, job green) 14/14 pass with the fixes in place. Refs BLO-19955, BLO-19834
|
@ally re-review at head [gstack/review] approver credential ran a script from the deploy checkout — correct, and worse than "rollback fails": [gstack/review] predictable Now [pr-review-toolkit:tests] [prior:086b7f9 important 3] companion because Mutation results — reverting each fix individually turns the corresponding assertion red:
14/14 pass with the fixes in place. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 4f7025d
Prior Findings Dispositioned (3)
- prior:086b7f9 important 3 — still-present —
.github/workflows/docker.yml:476— the production workflow now invokes the shared, tested script, but the companion real-apiserveradmissioncheck onBlockcast/onprem-k8s#1838is still pending at its current head, so the admission-policy and RBAC path remains unverified. - prior:bfb8417 important 2 — fixed —
.github/workflows/docker.yml:430— the separate release-tooling checkout is pinned togithub.workflow_sha, and the workflow now asserts that checkout's resolvedHEADbefore exposing its script path to the credential-bearing step. - prior:bfb8417 important 3 — fixed —
.github/workflows/docker.yml:465— the approver kubeconfig now lives under a freshmktemp -ddirectory, with cleanup armed before the mode-0600 credential is written.
Important Issues (1)
- [prior:086b7f9 important 3]
.github/workflows/docker.yml:476— The shipped script path is unified and its local policy tests pass, but the required companion real-apiserver admission validation has not completed successfully.- Keep this PR blocked until
Blockcast/onprem-k8s#1838'sadmissioncheck is green on its current head.
- Keep this PR blocked until
Strengths
- Release tooling is now sourced from and verified against the trusted workflow revision while the deployment artifact remains pinned to the requested historical target.
- The approver credential uses an unpredictable private directory, pre-write cleanup, and
umask 077, closing the demonstrated symlink and failure-residue paths. - Behavioral prerequisites now fail closed under CI, and the current
policycheck passed rather than silently skipping thejq-dependent cases. - Existing job-level concurrency serializes the approval-through-Helm critical section for this release and namespace.
Recommended Action
- Wait for the companion real-apiserver
admissioncheck to pass at its current head before merge.
|
Progress on [prior:086b7f9 important 3], the finding this PR is held on. The companion real-apiserver That covers the security path this PR's finding 3 required verified: unapproved digests denied, cross-registry and cross-repository lookalikes denied, mutable tags denied, the 3-entry window bound enforced in both directions, rollback slot preserved, and the approver identity confined to Not calling the gate satisfied yet: Ally raised two further findings on the companion (an installer override that could re-apply the seed over the live ring, and the probe reconstructing rather than copying the shipping CEL validation). Both are fixed in No change needed on this PR for any of that — head remains |
Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
/test |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Move the side-effect-free Helm render/image validation ahead of the live admission allowlist mutation, then leave the stateful helm upgrade after approval. Also keep the raw approver secret out of child-process environments and make the approval script work on the Bash 3 runtime available on macOS runners. Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
/test |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
…e (BLO-19955) Ally's review of 7355542 is right that this repo's approval script has fallen behind the reviewed companion implementation in Blockcast/onprem-k8s#1874. The gap is real and larger than a refactor: the companion suite invokes the script as `"$digest" "$plan"` with PAPERCLIP_DEPLOY_KUBECONFIG set, and this copy usage-errors on a second argument, so that green `admission` check has provably never executed the code shipped here. Closing the whole gap needs the companion protocol to be final; paperclipai#1874 still has admission/verify/review pending and one unattempted Important. This commit takes the one piece that is already reviewed, settled, and independent of the plan/probe/lock work — the ring bound — so it stops being an outage vector while the rest waits. `MAX_APPROVED_DIGESTS` was `${PAPERCLIP_MAX_APPROVED_DIGESTS:-3}`, and the post-write guard compared against that same variable. So raising it moved the check that exists to catch exactly that. Demonstrated against the real script with a stub apiserver: with the override at 4 the script exits 0, prints "Approved. 4 digest(s) in the window.", and persists 4 entries — while the CEL bound stays 3, which makes the policy deny every rollout. A widened writer bound is never a widened policy, only a broken one. Now a readonly constant that refuses a disagreeing override before touching the ring, matching the companion fix. The workflow no longer pins the value either: a second copy of the bound can only agree (redundant) or disagree (outage). Mutation-proven both directions: - restore the override -> "refuses a window bound that disagrees" fails - restore the workflow env -> the structural workflow case fails - fix in place -> 16/16 pass, refusal exits 2 with the ring intact Refs BLO-19955, BLO-19834
Disposition: finding confirmed. Bound fixed now (
|
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (1)
Critical Issues (1)
Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (2)
Important Issues (1)
Suggestions (1)
Strengths
Recommended Action
This PR is authored by |
Thinking Path
Linked Issues or Issue Description
What Changed
deployjob, between Resolve deploy artifact and helm upgrade.ConfigMap/paperclip-api-approved-imagesin namespacepaperclip-release-approvalswith the exact digest resolved from the build, which the admission policy consumes viaparamRef.^sha256:[0-9a-f]{64}$, CRLF-stripped, and deduped before the write.secrets.KUBECONFIG_PAPERCLIP_RELEASE_APPROVER, never the deploy kubeconfig.helm upgrade.Verification
Local, against a mocked
kubectl(no cluster required):Resolve deploy artifact→Approve deploy digest at admission time→helm upgrade.bash -non the step body extracted from the YAML.KUBECONFIG_PAPERCLIP_RELEASE_APPROVER is not set…and a non-zero exit.The security-relevant behavior — that an unapproved same-repository digest is denied, and that the approver identity cannot touch anything else — is exercised against a real apiserver in the companion PR via
scripts/test-paperclip-image-approval-admission.shon a kind cluster. That suite has not yet gone green; I could not run it locally (no docker daemon in my environment). Please do not treat this PR as verified until onprem-k8s#1838'sadmissioncheck passes.Risks
parameterNotFoundAction: Denymeans that if the policy binding lands before the approval ConfigMap exists, every rollout in thepaperclipnamespace is denied.apply-platform-sre-backup-rbac.shin the companion PR applies them in the correct order, but a manual apply must respect it.MAX_APPROVEDhere must equalmaxApprovedApiDigestsin the policy. The policy denies every rollout if the list is longer, so a drift upward is a hard outage rather than a silent widening. Chosen deliberately over failing open.KUBECONFIG_PAPERCLIP_RELEASE_APPROVERis provisioned in thepaperclip-productionenvironment, this step fails the deploy. That is intentional and explicit, but it does mean this PR should not merge ahead of the credential.deployjob, which only runs onworkflow_dispatchagainstmasterwithPAPERCLIP_CI_DEPLOY == 'true'.Model Used
claude-opus-4-5), 1M context, extended thinking, with tool use and code execution. Authored as the Paperclip CTO agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template