feat(sec): provision the narrow conformance CI trigger credential -- and refuse to arm a fake green
gate / gate (pull_request) Successful in 1m34s
gate / conformance (pull_request) Has been skipped

docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6, as an operator script:
infra/gitea/conformance-trigger-kubeconfig.sh. UNEXECUTED here -- minting a
cluster credential and changing Gitea variables are operator actions.

It mints a token for conformance/conformance-trigger (TokenRequest), wraps ONLY
that token with the cluster CA and server into a kubeconfig, and stores it as the
Actions secret CONFORMANCE_TRIGGER_KUBECONFIG. Never cluster-admin, never a copy
of the operator's own kubeconfig. The token is never printed and never reaches
argv: it goes into a 0600 file and into the PUT body FILE (--data @file), and the
script refuses to run under `set -x` because xtrace would print it.

Two traps this repo already paid for are designed around:

  * GITEA_ is a RESERVED secret-name prefix (HTTP 400 "invalid secret name"), so
    the name is CONFORMANCE_TRIGGER_KUBECONFIG -- which is also what gate.yml and
    merge-gate.yaml already reference.
  * `curl -sS` exits 0 on an HTTP error. Every write checks %{http_code} AND reads
    the object back. The secret read-back proves presence by NAME only (Gitea
    never returns secret values) and the script says so rather than implying its
    content was checked; the variable read-back asserts the value is exactly
    "true", which is what gate.yml compares against.

PRECONDITIONS ARE SPLIT IN TWO, and the split is the security argument:

  mint-blocking (cluster) -- SA/Role/RoleBinding present, and ClusterPolicy
  conformance-trigger-job-shape present + Enforce + failurePolicy Fail + Ready.
  Any failure and NOTHING IS MINTED. Kubernetes places no `use` check on a Job's
  serviceAccountName, so a `create jobs` token in that namespace IS the privileged
  conformance-runner identity unless admission bounds its shape. RBAC cannot close
  that (plan §4); minting before the boundary is live hands CI the escalation the
  boundary exists to close.

  arm-blocking (repo) -- digest-pinned CONFORMANCE_RUNNER_IMAGE, an initContainer
  plus the admitted image in chainsaw-job.yaml, and CONFORMANCE_TRIGGER_KUBECONFIG
  referenced in ci/conformance.sh CODE rather than a comment. These block only
  vars.CLUSTER_AVAILABLE, because storing the secret early is harmless (the job is
  gated off) and is the plan's own order.

All three arm-blocking checks FAIL in the tree today, so a live run would store
the secret and then REFUSE to arm CLUSTER_AVAILABLE, naming each missing item.
That is the intended behaviour: measured 2026-09-29, CLUSTER_AVAILABLE=true with
no trigger path scaffold-skipped all 54 tests at rc 0 and the stage printed "L2/L3
conformance passed". ci/conformance.sh now blocks that case, so arming early does
not enable L2/L3 -- it only breaks every PR. Arming needs the explicit flag, every
precondition green, and a typed confirmation on a TTY.

Before storing anything, the credential is PROVEN narrow: auth whoami must be
system:serviceaccount:conformance:conformance-trigger, a real `get jobs` round-trip
must succeed, and an auth can-i matrix asserts both halves -- granted (create/get
jobs, get pods, get pods/log) and DENIED (delete jobs, create pods/exec, get
secrets, create jobs in default, get nodes, '*' '*' --all-namespaces). A mismatch
aborts before any store, and says explicitly not to widen
conformance/incluster/rbac.yaml to make it pass.

Verified only by `bash -n` (clean) and reading. The Gitea variables API shape
(POST creates, PUT updates, body field `value` not `data`) is from documentation,
not measurement: the script tries POST then PUT, prints the response body on
failure, and treats the read-back as the authority. Kyverno readiness is accepted
from either .status.ready or a Ready condition, and "neither present" blocks
rather than assuming ready.

Gate (pinned toolbox): MERGE GATE: UNDETERMINED (rc 3) -- spec-immutability and
gitea-settings unrun, the expected local result.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-09-29 19:12:11 -05:00
1 parent 20ea70d221
commit 5a006aeb15
3 files changed
+709

No files matched your search

+81
View File
@@ -5109,3 +5109,84 @@ next L2/L3 piece on top of unmerged branches would create a rebase tangle for no
cannot land any faster. The next piece is specified and ready to start once these are merged:
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
boundary landed in #13.
## 2026-09-29 (agent, branch `feat/conformance-trigger-kubeconfig`) — the trigger credential script: it REFUSES, on purpose
Delivered `infra/gitea/conformance-trigger-kubeconfig.sh` —
`docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 — plus its section in
`infra/gitea/README.md`. **The script is UNEXECUTED.** Minting a cluster credential and changing
Gitea variables are operator actions; nothing was minted, no Gitea object was written, no
`kubectl` mutation was made. Everything below is a claim about the *script*, not about the
platform.
### What it does
Mints a token for `conformance/conformance-trigger` (TokenRequest), wraps **only that token**
with the cluster CA and server into a kubeconfig, stores it as the Actions secret
`CONFORMANCE_TRIGGER_KUBECONFIG` (legal name — **not** the reserved `GITEA_` prefix), and sets
`vars.CLUSTER_AVAILABLE` only as a separately-requested, precondition-gated, TTY-confirmed final
step. The token is never printed and never reaches argv: it goes into a 0600 file and into the
PUT **body file**. The script refuses to run under `set -x`.
### The design decision worth reviewing: TWO precondition classes
- **mint-blocking (cluster-side):** SA/Role/RoleBinding present, and ClusterPolicy
`conformance-trigger-job-shape` present + `Enforce` + `failurePolicy: Fail` + Ready. If any
fails, **nothing is minted**. Rationale: Kubernetes puts no `use` check on a Job's
`serviceAccountName`, so a `create jobs` token in that namespace IS the privileged
`conformance-runner` identity unless the admission policy bounds it. RBAC cannot fix that
(plan §4); minting before the boundary is live would hand CI the escalation the boundary
exists to close.
- **arm-blocking (repo-side):** digest-pinned `CONFORMANCE_RUNNER_IMAGE`, an init-container plus
the admitted image in `chainsaw-job.yaml`, and `CONFORMANCE_TRIGGER_KUBECONFIG` referenced in
`ci/conformance.sh` **code rather than a comment**. These block only the variable, because
storing the secret early is harmless and is the plan's own order.
**Measured in the tree today: all three arm-blocking checks FAIL** (no
`CONFORMANCE_RUNNER_IMAGE` in `conformance/versions.env`; `chainsaw-job.yaml` is still the
skeleton with an empty `workspace` emptyDir and the stock chainsaw image; `ci/conformance.sh`
names the trigger kubeconfig only in its TODO comment). So on a live run today the script would
provision the secret and then **refuse to arm `CLUSTER_AVAILABLE`**, naming each missing item —
which is the intended behaviour, not a defect. §3 items 1, 2 and 4 are the work that unblocks it.
### Verification of the credential before it is stored
`auth whoami` must return `system:serviceaccount:conformance:conformance-trigger`; a real
`get jobs` round-trip must succeed; then an `auth can-i` matrix asserts BOTH halves — granted
(`create/get jobs`, `get pods`, `get pods/log`) and **denied** (`delete jobs`, `create pods/exec`,
`get secrets`, `create jobs` in `default`, `get nodes`, `'*' '*' --all-namespaces`). A mismatch
aborts before anything is stored, and the message says explicitly **not** to widen
`conformance/incluster/rbac.yaml` to make it pass.
### Both writes are verified the way `set-ci-token.sh` learned to
`curl -sS` exits 0 on an HTTP error, so status alone proves nothing: each write checks
`%{http_code}` **and** reads the object back. For the secret that is presence-by-name only
(Gitea never returns secret values) and the script says so rather than implying the content was
checked; for the variable it asserts the value reads back as exactly `true`.
### UNVERIFIED — state this plainly
Only `bash -n` was run (clean); `shellcheck` is not on the host and there is no shellcheck stage
in the gate. Nothing in the script has *executed*: the Gitea variables API shape (POST-creates /
PUT-updates, body field `value` not `data`) is from documentation, not measurement — the script
tries POST then falls back to PUT and prints the response body on failure, and the read-back is
the authority. The Kyverno readiness field (`.status.ready` vs. a Ready condition) is handled
both ways and treats "neither present" as a block rather than assuming ready. Whether the
kubeconfig's embedded server URL is reachable from the runner pod cannot be known from here —
hence `--server`, and a hard refusal on a loopback address.
### Noted, not fixed (out of scope for this branch)
`infra/gitea/README.md`'s detector table still names `GITEA_SETTINGS_TOKEN` — the illegal name
PR #15 replaced with `SETTINGS_API_TOKEN`. Harmless prose today, but it is the exact trap that
directory documents. Worth a one-line docs PR.
### Gate (pinned toolbox, from the agent worktree)
`MERGE GATE: UNDETERMINED`, **rc 3**. `lint`, `l0`, `render-golden`, `l1` all green; touched
requirement resolved as `R-ENV-01`. Stages that did not run: `spec-immutability` (sibling spec
repo not checked out — the documented tolerated skip) and `gitea-settings` (`SETTINGS_API_TOKEN`
is a credential this agent does not hold). Both are the expected local result and `gate.yml`
tolerates exit 3; neither was "fixed". Run with `DEV_KUBECONFIG=/dev/null` and an `ENGINE` shim
bind-mounting the main checkout's `.git` read-only, per `CLAUDE.md` §3.