Files
ersatztv/docs/decisions/records/ci/actions-credential-scoping.md
T
timothyandClaude Opus 5 691a7acc77 fix(743): disable direct pushes to main so review-verdict/h10 is on the only path to main
Gitea evaluates `status_check_contexts` when it MERGES a PR. A direct
`git push origin HEAD:main` never consults them, so the whole h10 gate was
skippable with no forgery — strictly cheaper than every route enumerated in
#697. `main` now carries `enable_push: false`.

Measured on this instance (Gitea 1.27.1) against a throwaway `probe-743-*`
rule rather than against `main`:

  enable_push: false                      -> push by timothy (site admin)
                                             REFUSED, pre-receive hook declined
  enable_push_whitelist + ["timothy"]     -> identical push SUCCEEDED

That second line is why this is a DISABLE and not a whitelist: #743 offered the
two as interchangeable, but the only write accounts here are `timothy` (site
admin) and `renovate`, and every credential in the threat model — agent
sessions, PATs, the injected GITEA_TOKEN — acts as `timothy`. A whitelist
naming `timothy` would have ticked the box and closed nothing.

Then demonstrated on `main` itself, per the issue's Done-when: a direct push
was refused, and a tag-only push from the same worktree succeeded (tags are
governed by `tag_protections`, which is empty). The release cut is unaffected.

What this closes: the write-only credential routes — the injected GITEA_TOKEN,
RENOVATE_TOKEN, any non-admin collaborator PAT. What it does NOT close: an
admin credential can PATCH the protection off, push, and restore it. Recorded
as an accepted residual rather than implied to be covered.

Also corrects two claims the probe contradicted, and one that the mid-session
Gitea upgrade (1.25.4 -> 1.27.1) invalidated:

- ci-cd.md and ci.actions-credential-scoping both said "a push whitelist would
  close more of this class than the 1.26 upgrade". The whitelist form closes
  nothing here; corrected in place.
- ci.actions-credential-scoping's rule said "do NOT add a `permissions:` key
  while this instance is below Gitea 1.26.0". That precondition no longer
  holds at 1.27.1, so the directive now misleads. Corrected — while noting the
  consequence is still UNVERIFIED: `/api/v1/settings/actions` 404s at 1.27.1,
  so whether `permissions:` binds here was not probed. The upgrade alone is
  not evidence the constraint works.
- That record's 1.25.4 measurements are now dated, not current. Flagged as
  such rather than silently re-pinned to a version they were never taken on.

#743's fourth box (docker-build.yml `persist-credentials: false`) is decided
in the record and deliberately not done here: two of its checkout steps run
`git fetch ... || true` feeding the changed-file skip logic, so a credential
regression would be silent rather than loud. Drop the `|| true` masking first.

fixes #743

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:31:45 +02:00

10 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.actions-credential-scoping 2026-08-05 — CI's registry credential is a scoped PAT, not the admin password, because Gitea cannot separate status-write from repo-write (#697) active 2026-08-05 none none Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. Do NOT add a `permissions:` key to constrain the injected `GITEA_TOKEN` on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP, which is worse than absent because it reads in review as a constraint. That version precondition NO LONGER HOLDS: this instance was upgraded 1.25.4 -> 1.27.1 on 2026-08-05. What has NOT changed is that the consequence is unverified — whether `permissions:` is honored here, and what this instance's default Actions token permission is, were both left UNPROBED (there is still no API surface: `/api/v1/settings/actions` 404s at 1.27.1). Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter (`ci-image.yml`, tracked in #744 — deliberately NOT bundled here, because editing that file re-points `ci-image-pin` at the editing commit and reddens a blocking job). This record closes ONE route. It does not close the class, and four later sections say exactly what survives — read them before citing this record as a mitigation. admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #420, server-management#714 PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea's scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != 'pull_request'`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.

What was wrong. REGISTRY_USER/REGISTRY_PASSWORD were the admin account's basic auth, and docker-build.yml triggers on pull_request — head-resolved — so a PR's own code got instance-admin credentials. Basic auth carries no scope: the secret pushing an image administers every repo on the instance.

Why the credential and not only the triggers. Patching triggers enumerates instances of "a ref-resolved workflow obtains status-capable credentials", and adding a new workflow file is itself a route, so that enumeration never completes. But it is not either/or: ci-image.yml's unfiltered push: is path-scoped to itself, so any branch push runs attacker YAML on a docker-capable runner with no PR. Scoping bounds what a job may DO; only a filter bounds whether it RUNS. That filter is #744, not this record: editing ci-image.yml re-points ci-image-pin's expected at the editing commit and staleness-fails a blocking job. That is a toll, not a wall — the documented two-step (publish :<short sha>, then bump all five pins) clears it — but a rebase rewrites the sha and charges it again, so it lands alone (land-toolchain-image-change-separately).

What the scoped token still reaches — not "just a registry credential". write:package over owner timothy writes ersatztv:prod (the floating tag prod's jazz-media stack follows) and ersatztv-ci:<sha> (the toolchain image executing five container: jobs). A sha-named tag is not an immutable artifact (no container tag immutability in Gitea 1.25 — INFERRED), so overwriting the pinned tag is code execution inside CI, chaining back into the routes below. This is the deployment supply chain for prod and CI itself.

Admin ownership is a real residual. The PAT is minted under timothy, a site admin. The 403 proves the scope gate binds the status endpoint ahead of any admin bypass; it does NOT establish that for package endpoints, where Gitea resolves permission by owner and an admin passes object-level checks, so the token's package reach is plausibly wider than this repo. A non-admin bot account would close this, but is not free: packages live in a user namespace only its owner and admins can write. Both halves INFERRED, neither probed.

Provenance, corrected. review-verdict.yml leaves an existing h10 alone only when it is positively identifiable as human — non-null .creator.login plus a Review-verdict: description (release.verdict-status-check). A user credential posts with a real creator and is INHERITED; an Actions job posts creator: null and is re-derived. That asymmetry is not protection. Re-derivation fires only on opened|reopened|synchronize|ready_for_review|edited, and posting a status is none of them, so a POST timed after the last event stands until the attacker merges. The gain here is that PR code can no longer escalate to instance admin — NOT that the durable forgery route is closed.

The boundary is everything reachable from a job, not the secret store. The store is a useful lower bound — auditing it rather than the workflow set is what found RENOVATE_TOKEN and SERVERMGMT_DEPLOY_KEY below, since any PR-added workflow can reference any secret. But GITEA_TOKEN is injected and never in the store; nor is the credential actions/checkout persists into .git/config (docker-build.yml omits persist-credentials: false); and jobs reach the runner's docker daemon.

Measured vs inferred. Measured here: the v1.25.4 scope enum (access_token_scope.go) has no status entry; the reqRepoWriter gate (routers/api/v1/api.go); the probes in mechanics. Read from docs, NOT verified (2026-08-05): permissions: landed in 1.26.0 (Gitea PR #36173); no app.ini lever at any version; Gitea rejects GitHub's statuses/checks scopes.

Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1. The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is therefore a dated claim, not a current one: the scope enum, the reqRepoWriter gate and the 403 probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their date and are the best evidence available, but do not cite them as current behaviour without re-probing. Re-verification of the 1.25.4-pinned claims across the CI docs is tracked separately.

Surviving routes — this record is not a mitigation for any of them. RENOVATE_TOKEN is a write:repository bot PAT in the same store, posting with a real creator, and cannot be scoped down because Renovate needs repo write (#742). The injected GITEA_TOKEN is write-capable in every job; only Gitea >=1.26 with the Actions default set to Restricted binds it (server-management#714) — the version half of that condition is now satisfied (1.27.1) but the default half is unverified, so treat this route as still open until probed. A collaborator's own token always can. docker-build.yml publishes :prod from a v* tag push and a tag may point at ANY commit — a prod image with no PR, review or status (tag protections are empty). And none of it was necessary: direct pushes to main were server-side permitted, so the gate was bypassable with no forgery at all (#743). That route is now closed — main carries enable_push: false (release.main-direct-push-disabled), which removes main as a destination for every write-only credential in this list, including the injected GITEA_TOKEN and RENOVATE_TOKEN. It does not remove them as forgery routes on the PR path, and it does not bind an admin credential, which can PATCH the protection off first. Correction to this record's earlier wording: a push whitelist would NOT have closed more of the class than the upgrade — measured 2026-08-05, a whitelist naming timothy still admitted the push, and every credential here acts as timothy. Treat this list as "at least these", never exhaustive. SERVERMGMT_DEPLOY_KEY remains in the store though its bump-prod-compose job went in 1b5efd7b9, and its key on timothy/server-management is read_only: false — write access to the repo holding prod's GitOps stack definitions. Left in place by explicit decision 2026-08-05; recorded so it is accepted, not forgotten. Severity throughout: push access required, so a compromised contributor or subverted automated session, never an anonymous one.