Files
ersatztv/docs/decisions/records/ci/actions-credential-scoping.md
T
fb55929711
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 23s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m24s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m17s
fix(869,893): re-establish the 1.25.4-dated CI claims on 1.27.1, and settle the page_statuses asymmetry from source (#905)
Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/.

Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named.

Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution.

#893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away.

Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance.

Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN.

fixes #869
fixes #893

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-09-02 20:20:43 +00:00

26 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. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. 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. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`'s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo's checkouts now set it — 15 in #746 and `ci-image.yml`'s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`'s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885). admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, 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/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #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. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe) — note this means not probed, NOT unexercised, and the `container:` pull is in fact exercised by the shipped credential on every PR: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea's scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — 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 push: was unfiltered and path-scoped to itself, so any branch push ran 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 landed in #744, not this record, and its own decision is ci.toolchain-image-publish-is-a-dispatch: branches: [main], with branch publishing moved to a deliberate workflow_dispatch. It had to land alone (land-toolchain-image-change-separately) because, until it also removed the file from ci-image-pin's expected paths, editing ci-image.yml re-pointed that job at the editing commit and staleness-failed a blocking check on every rebase.

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 an approved reviewer's verdict — a .creator.login in the workflow's H10_REVIEWERS allow-list plus a Review-verdict: description (release.verdict-status-check, ci.exemption-provenance). An allow-listed user credential posts with a matching creator and is INHERITED; an Actions job posts creator: null and is re-derived, and so now is every other account.

The RENOVATE_TOKEN INHERITANCE route is closed by the allow-list, not by scoping (#742), because scoping was not available — and inheritance is the only half it closes. A status POSTed with that token still satisfies branch protection if it is timed after the last PR event; what it can no longer do is survive as a verdict across every later run of the workflow. That token needs write:repository to do its job — pushing branches and opening PRs — so there is no write:package-shaped separation to exploit the way there was for the registry credential, and secrets are a per-repo store any workflow can reference. The test was therefore tightened at the reader instead: creator IN <allow-list> rather than creator != null, which re-derives a renovate-posted verdict exactly as it re-derives an Actions-token one. Scoping and allow-listing close the same class from opposite ends; where a credential cannot be narrowed, narrow what the consumer will accept from it. The creator-shape asymmetry — an ALLOW-LISTED creator's success INHERITED, everything else re-derived — is not protection. (Since #742 "a real creator" is no longer the test: only H10_REVIEWERS membership inherits a success, while a failure inherits on attributability alone.) 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 (dropped on 15 of the repo's 16 checkouts in #746 and on the sixteenth in #744 — see below); and jobs reach the runner's docker daemon.

The persisted checkout credential (#746). actions/checkout writes an Authorization header into .git/config unless persist-credentials: false is set, so a job hands that credential to every script it runs, not only to the checkout — and it is write-capable while this instance's Actions default token permission is permissive — where it stands after a Restricted trial was reverted on 2026-08-05 (#748). All 16 of the repo's checkouts now set it: docker-build.yml (8), pr-checks.yml (5), dependency-scan.yml (1), review-verdict.yml (1, pre-existing) and ci-image.yml (1, added in #744). No job that checks out pushes with git, so none of them loses a capability; renovate.yml does push, but has no checkout step and uses RENOVATE_TOKEN.

Unmask the dependent fetches BEFORE dropping a persisted credential — the rule, not a note about one change. Five steps fed git fetch ... origin "$base_ref" || true into changed-file skip logic, where a credential regression reddens nothing: on the three that also masked git diff it yields an empty changed set the skip logic reads as "nothing changed", and on the other two it hands a possibly-absent origin/<base> to a validator. Anonymous fetch does cover this repo (public; git-upload-pack answers unauthenticated, measured 2026-08-26), which is a reason to make the failure loud rather than a reason to trust it silently. The same step is where --depth came off those fetches: their jobs check out fetch-depth: 0, and git fetch --depth=N grafts a complete clone shallow, so a three-dot diff against a branch point further back than N fails no merge base — invisible under || true, and without it a red api-docs/Formatting (decisions lifecycle would stay green with its diff-dependent checks skipped, because its validator fails open on an unresolvable base).

ci-image.yml's single checkout was the one exception, and never because it was safe — its unfiltered push trigger made it head-resolved in the sense that matters. It was excluded because editing that file re-pointed ci-image-pin's expected at the editing commit and reddened a blocking job, and because the same trigger would publish a toolchain image from the editing branch. #744 removed both (ci.toolchain-image-publish-is-a-dispatch) and set the flag in the same change, so the count is now 16 of 16 with no exemption. That is what let #835 ship the guard — scripts/tests/test_workflow_persist_credentials.py, population derived from the git index — with no exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing after that condition expires, leaving the guard permanently blind to the file it was written for.

Measured vs inferred. Measured here: the 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.

RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES. It discharges those three and only those three. It does NOT discharge every dated statement in this record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and the caveat is kept rather than deleted because it names what each item rests on.

  • Scope enum — no status scope. Read at tag v1.27.1, models/auth/access_token_scope.go — the same path it occupies at v1.25.4, checked in both trees rather than assumed, because this record had only ever named the file and not its directory. Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue, Repository, User — and the string status does not occur in the file at all. write:package, read:repository and write:repository remain distinct scopes, which is what keeps the registry credential scopable off status-write at no cost.
  • The reqRepoWriter gate. Read at tag v1.27.1, routers/api/v1/api.go: the /statuses group registers m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus). Unchanged in substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE, so any credential that can write the repository can still forge review-verdict/h10.
  • The write:package 403 — re-probed live, with both controls. A PAT scoped exactly write:package,read:repository, minted against the live 1.27.1 instance, POST /repos/{o}/{r}/statuses/{sha} -> 403, message byte-for-byte the 1.25.4 one: token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository. Two controls, because a bare 403 is equally consistent with a dead token or a dead route: the SAME token GETting the same path -> 200 (the token is live and the read scope works), and an admin credential POSTing the same path -> 201 (the route accepts a POST). Be precise about what the second control licenses — it changes the credential, so scope and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the message as the evidence and the control as its backstop, not the other way round. Run in a throwaway private repo created for the probe; repo and token both deleted afterwards and the deletion confirmed (repo GET -> 404, token DELETE -> 204), so nothing was written to a real head.

MEASURED 2026-08-27 on 1.27.1 (#748). It re-probed exactly ONE of the old 1.25.4 caveat's items, the permissions: question, and supersedes the "whether permissions: is honored here is UNPROBED" claim. It did not by itself discharge that caveat — the scope enum, the reqRepoWriter gate and the write:package 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869 re-established all three on 2026-09-02, which is what finally discharged it (see the block above and "Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission unit, the block carried at job level on set-verdict-status — the same level and job as the shipped review-verdict.yml, so the gate's own shape is what was measured.

  • code: write -> job success, Posted review-verdict/h10-PROBE=success, status creator: null.
  • code: read -> job failure at the POST, curl exit 22 (HTTP >=400 under -f), NO status written.

What each observation licenses, kept separate. issues: read was exercised: both runs logged Retarget fence: 0 retarget event(s) ... (trusted=yes), which requires the timeline walk to have read /issues/{n}/timeline. (That line and count_retargets were renamed by #803, which put the head axis on the same walk: Mutation fence: N retarget event(s) and M push event(s) ... and count_pr_mutations. The quoted string is what those runs actually printed and is kept verbatim; reproduce against the current names.) pull-requests: read was exercised: the positive control posted =success, reachable only through complete=yes, which requires scripts/pr-changed-files.sh to have paged /pulls/{n} and /pulls/{n}/files to a validated end — the fence and the start marker do NOT show this, the success does. Both reached the classify step's start marker, so neither was the #751 dropped-step shape.

The negative control is the load-bearing half — without it a green positive is equally consistent with permissions: being a no-op sitting beside a permissive default.

NOT established: what a declared code: write receives under a RESTRICTED owner default (both controls ran under permissive); and the TOP-LEVEL declaration form the other five workflows use, which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.

Version history — the 1.25.4 caveat, and what discharged it. The instance was upgraded mid-session on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — ci.docs-only-skip-steps, ci.shared-pr-file-enumeration, the REST-route half of ci.batch-pushes-no-cancel-route, the compare half of ci.exemption-provenance and the branches: [main] item in ci.gate-trigger-base-resolved — and it was not a sweep of the corpus. #869 then swept the corpus by deriving the population with git ls-files rather than from an issue's item list, and re-established most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and nothing here tracks which ones, deliberately. Two attempts at that tracking both failed inside the commit that wrote them: an enumerated list named three residuals and omitted three the same commit created, and its replacement — a git grep pointer — missed the residual that same commit created, because the commit had introduced a second spelling the grep did not match. A second copy of a set, whether written out or derived by a pattern, is one more thing to keep in step with the set. Each residual states its own reason where it lives, which is the copy that cannot drift from its subject. This record's own three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see release.main-direct-push-disabled for the measured block_admin_merge_override semantics and for why enable_bypass_allowlist is not a substitute.

Read the two source items as source-attested, not instance-attested, and know the difference. The scope enum and the reqRepoWriter gate are established by reading Gitea's source at tag v1.27.1, which is the version this instance reports (GET /api/v1/version -> 1.27.1, 2026-09-02). That is a strong claim about the code and a weaker one about the deployment: a patched or vendored binary reporting 1.27.1 would satisfy the version read and not the source read. Nothing suggests one here, and the third item — the live 403, whose error string enumerates the required scope — is what ties the source to this instance. Cite the trio together rather than the source pair alone.

Surviving routes — this record is not a mitigation for any of them. RENOVATE_TOKEN is a write:repository bot PAT in the same store and still cannot be scoped down, because Renovate needs repo write; what changed is that a verdict posted with it is no longer inheritedrenovate is not in H10_REVIEWERS, so review-verdict.yml re-derives it (#742). It remains status-capable, so it can still overwrite a status; it can no longer impersonate a reviewer. The injected GITEA_TOKEN was write-capable in every job. That is no longer the mechanism: #748 declared permissions: on all six workflows (2026-08-27), and because the declaration binds under the current permissive default, five hold code: read and only review-verdict.yml's gate job holds write. So the route is narrowed NOW, without the owner-level flip. The owner-level Restricted default (server-management#714) remains worth applying as the instance-wide floor for repos that declare nothing — but it is NOT yet shown safe here: whether a declared code: write survives a Restricted default is unmeasured, and if Gitea caps at the default the way GitHub does, the flip would make the gate unwritable. What is NOT closed: review-verdict.yml still holds repo-write, so the property is "only a workflow visibly opting into write can assert the verdict", not "no workflow can". Scoping still bounds what a job may DO, never whether attacker YAML RUNS. A collaborator's own token always can — and after #742 an ALLOW-LISTED collaborator's still is inherited, which is the residual the allow-list explicitly does not touch. Which route is cheapest was settled in #853 (ci.workflow-dispatch-ref-unrestricted): not the four unrestricted workflow_dispatch triggers but docker-build.yml's head-resolved pull_request:, which runs attacker-authored YAML and therefore reaches EVERY secret in the store, not only the ones the committed workflows name — six of its jobs already hold REGISTRY_PASSWORD on that route, two of them required contexts. Residuals in #885. 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.