ci(deploy): capture a production admission denial receipt for paperclip-api (BLO-33027) - #1751
Merged
allyblockcast[bot] merged 2 commits intoSep 10, 2026
Conversation
…clip-api (BLO-33027)
ValidatingAdmissionPolicy/paperclip-api-image-approval is installed and
undrifted on the production apiserver, but had never been *evaluated* there.
Measured 2026-09-09 and re-confirmed 2026-09-10:
apiserver_validating_admission_policy_check_total{policy="paperclip-api-image-approval"}
has zero samples, while five other policies report into the same metric family
— so the family is scraped and this policy had simply never fired. Enforcement
rested entirely on a kind-cluster CEL assertion and a drift gauge over the
Binding: a sound inference chain, but every link off-cluster or indirect.
The deploy job ends with a negative probe that replays the live paperclip-api
Deployment through `replace --dry-run=server` with exactly one field changed —
the container image, set to an obviously-synthetic unapproved digest in the
real repository — and requires the apiserver to deny it, naming both
`paperclip-api-image-approval` and `approved immutable digest`. That denial is
the receipt.
No RBAC is added, widened, or bound. It runs as the pre-existing deploy
identity (KUBECONFIG_PAPERCLIP_CI_DEPLOY), which already holds deployment write
in `paperclip` because that is what `helm upgrade` requires. This moves the
venue of the proof, not the bar.
Reports, does not gate — deliberately, for two reasons. It runs after the
rollout has landed and been verified, so no probe outcome can delay a
legitimate release; and scheduled-production-deploy.yml decides whether
production is behind by looking for a `deploy` job with conclusion `success`,
so failing the job would make a landed release look undeployed and re-request
human production approval on every schedule tick forever. The step still
`exit 1`s, so a regression surfaces as a failed step plus an ::error::
annotation; `continue-on-error` keeps `outcome: failure` and overrides only
`conclusion`.
Takes UPDATE semantics against the live object on purpose: preserving immutable
fields such as spec.selector byte-for-byte is what keeps the denial
attributable to image validation rather than to "field is immutable".
The guard test executes the extracted step rather than grepping it, for the
reason check-docker-retire-in-flight-lock.test.js gives — #1636's review showed
presence-only assertions passing against mutated code. It covers the full
verdict matrix (admitted, denied-by-policy, denied-by-another-policy, the same
policy's other validation, storage-layer conflict, unreachable apiserver),
asserts only the image is mutated, that a generation change outranks any
verdict, and that the live Deployment's container env never reaches the step
log or job summary. Verified with teeth: three independent mutations
(neutered admit-path, half-dropped attribution, "any non-zero exit is a
denial") each fail the suite.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Author
…n binding (BLO-33027) The probe's provenance comment credited RoleBinding/paperclip-ci-deploy-admin -> ClusterRole/admin for the deployment write it relies on. That binding does still exist, but onprem-k8s paperclip/ci-deploy-rbac.yaml annotates it `blockcast.net/deprecation` (BLO-20052): it is superseded by RoleBinding/paperclip-ci-deploy -> Role/paperclip-ci-deploy and kept live only until a real CI deploy is verified green against the scoped Role. Cite the scoped Role instead, and quote the rule verbatim — apps/deployments with create/update/patch — so the next reader can check the claim against the GitOps source rather than an unverifiable creation date. Verified there directly; my own seat cannot read rolebindings or clusterroles on-cluster (Forbidden) and cannot impersonate, so the declarative source is the authoritative reference available. Also notes a real side effect: the step's `kubectl auth can-i` lines run as the deploy identity on every release, which is exactly the verification the deprecated binding is waiting on — so this step helps retire it rather than depending on it. Comment-only; above the extraction markers, so the guard suite is unaffected (16/16 pass, and `policy` passed in CI at the previous head). Co-Authored-By: Paperclip <noreply@paperclip.ing>
Author
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: 99b806a
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The production probe uses the live Deployment as its update base, changes only the image, uses
--dry-run=server, and checks generation before and after to keep the denial receipt side-effect-free. - The test suite executes the extracted workflow shell against a stubbed
kubectland covers attribution, admission, retry, inconclusive, secret-redaction, and workflow-ordering paths rather than relying on presence-only assertions. - The probe is explicitly non-gating and runs after rollout verification, preserving the existing deploy-dispatch semantics while surfacing enforcement regressions through step failures and annotations.
- The latest documentation correction identifies the scoped
ci-deployRole as the authoritative grant instead of relying on the deprecated admin binding.
Recommended Action
- No Critical or Important issues found; this review is ready for the normal merge process.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
What Changed
.github/workflows/docker.yml— new final step indeploy,Negative admission probe — production denial receipt (BLO-33027). It reads the livepaperclip-apiDeployment, changes onlyspec.template.spec.containers[].imageto a synthetic unapproved digest in the real repository, and submits it viakubectl replace --dry-run=server. A denial naming bothpaperclip-api-image-approvalandapproved immutable digestis the receipt; anything else is a failure or an explicit inconclusive.scripts/check-docker-admission-denial-receipt.test.js(new, 16 tests) — extracts the step's real shell between explicit markers and executes it against a stubbed apiserver with realjq..github/workflows/pr.yml— wires that suite in as a step, matching the convention of itscheck-docker-*neighbours.Three deliberate design decisions, all pinned by tests:
continue-on-error: trueis load-bearing rather than lazy:scheduled-production-deploy.ymldecides whether production is behind by scanning for adeployjob withconclusion == "success", so failing the job would make a release that did land look undeployed and re-request human production approval on every schedule tick, forever. The step still genuinelyexit 1s —continue-on-errorpreservesoutcome: failureand overrides onlyconclusion— so a regression surfaces as a failed step plus an::error::annotation.spec.selectorbyte-for-byte, which is what keeps the denial attributable to image validation instead of tofield is immutablefrom the wrong source.sha256:deadbeef…) yet well-formed against the policy's own^sha256:[0-9a-f]{64}$filter, so the denial stays attributable to non-approval rather than malformed input — and no build can ever emit it, so it cannot collide with a real artifact or be approved by accident.Verification
No RBAC added, widened, or bound — the central claim. The probe runs as the existing
KUBECONFIG_PAPERCLIP_CI_DEPLOYidentity. This PR contains no Role, RoleBinding, ClusterRole, or ServiceAccount change:Premise confirmed before writing the step —
paperclip-ci-deployexists inpaperclip(age 133d), and the identity demonstrably writes Deployments there today:Policy and Binding both present and undrifted:
The gap this closes, measured 2026-09-10 — five policies report, this one has no samples:
Guard suite, 22s wall clock (step allows 120s):
The tests have teeth — presence-only assertions are the failure mode #1636 found, so each mutation below was applied to the real step and the suite re-run:
exit 1sapproved immutable digesthalf of attributionkubectlexit as a denialdocker.ymlwas confirmed byte-identical to its pre-mutation state afterwards (git diff --quietclean), so no experiment residue is in this branch.After merge, the first production release supplies the remaining evidence for the issue's acceptance criteria: the step log with the denial text verbatim,
metadata.generationidentical either side of the probe, and at least one sample inapiserver_validating_admission_policy_check_total{policy="paperclip-api-image-approval"}.Risks
Low, and bounded by construction — the probe cannot break a release. It is the last step in
deploy, runs only after the rollout has landed and been verified, andcontinue-on-error: truekeeps it out of the job'sconclusion, whichscheduled-production-deploy.ymlreads.--dry-run=servernever persists; a denied request persists nothing by definition; and the step comparesmetadata.generationeither side and fails loudly if it ever moved — a generation change outranks even a good denial verdict (pinned by a test).::error::annotation, not a blocked release.create/updateby direct RBAC introspection. My seat cannot readrolebindingsorclusterroles(Forbidden) and cannot impersonate, so I confirmed the premise empirically (Helm-managed, generation 577) rather than by reading the grant. The step itself printskubectl auth whoamiandkubectl auth can-ias the real identity on every release, which is stronger evidence than an impersonated--as=check and lands with the first run.Model Used
claude-opus-5), 1M context, extended thinking, agentic tool use — running as the PlatformSREEngineer agent under Paperclip.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatePaperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-33027