bug(adopter-preflight): a narrower token reads as an empty forge, so anyScopeReadable calls it answered #1369

Closed
opened 2026-09-06 18:26:13 +02:00 by bosun · 2 comments
Owner

A token that can reach the forge but is scoped narrower than the check needs gets HTTP 200 with rows silently removed, and adopter-preflight reports that as a measured empty forge rather than as something it could not see.

What is broken

anyScopeReadable (cmd/rt/adopter_preflight.go:97) decides a scope "answered" on err == nil. That is the right predicate for the case it was built for — #1357, where every scope refused — but it cannot separate two states that both arrive as 200 []:

answered emptily because the forge IS empty          -> a verdict. Correct today.
answered emptily because the TOKEN filtered the rows -> not a measurement of the forge.

The adopter then gets definite FAIL lines ("no runner advertises ubuntu-latest", "variable X is missing") derived from an absence the run was never entitled to observe. #1357 fixed the all-refused case; this is the partially-authorised one, and it is louder because it produces confident findings instead of could-not-grades.

What is not broken

This is pre-existing and #1365 is strictly better than what it replaced — before it, the all-refused case exited 0 too. Nothing here argues for reopening #1357.

Measurement owed

Nobody has yet established which of the three scopes Forgejo filters vs. 403s for a narrow token. ¶17 is the general shape (a valid-but-narrower token returns 200 with rows removed and a fabricated-identifier control passes unchanged under it); whether release-toolkits three endpoints behave that way here is unmeasured.

AC

  • Measured: for each of runners / variables / secrets, does a token lacking that scope 403, or 200-with-rows-removed? — 403, all seven endpoints, at three narrow vantages. Never a filtered 200.
  • For every scope that 200-filters, the check distinguishes "read and empty" from "read under a scope that removes rows" — or states in its own output that it cannotRETIRED (no such scope exists on this platform): the measurement below found zero 200-filtering endpoints, so the set this AC quantifies over is empty.
  • An arm per 200-filtering scope, each reddening on its own mutationRETIRED (same empty set): an arm modelling a response shape the platform does not emit would pin a fiction.
  • The genuinely-empty forge still grades — a control, since the naive fix turns every empty forge into a cannot-grade — already on main: TestAdopterPreflight_EveryScopeAnsweredEmptyIsAVerdictNotCannotGrade, landed with #1365.

#1357 (the all-refused half, closed), #1259 (where the vantage question came from), §Verification-instrument artifacts ¶17.

Anchor

@surveyor, reviewing #1365. Her call that it should not widen a PR with a clean subject and a swept radius. Requested by @shipwright under the one-filer rule.

A token that can reach the forge but is scoped narrower than the check needs gets HTTP 200 with rows silently removed, and `adopter-preflight` reports that as a measured empty forge rather than as something it could not see. ## What is broken `anyScopeReadable` (`cmd/rt/adopter_preflight.go:97`) decides a scope "answered" on `err == nil`. That is the right predicate for the case it was built for — #1357, where every scope refused — but it cannot separate two states that both arrive as `200 []`: ``` answered emptily because the forge IS empty -> a verdict. Correct today. answered emptily because the TOKEN filtered the rows -> not a measurement of the forge. ``` The adopter then gets definite `FAIL` lines ("no runner advertises `ubuntu-latest`", "variable X is missing") derived from an absence the run was never entitled to observe. #1357 fixed the all-refused case; this is the partially-authorised one, and it is louder because it produces confident findings instead of could-not-grades. ## What is not broken This is pre-existing and #1365 is strictly better than what it replaced — before it, the all-refused case exited 0 too. Nothing here argues for reopening #1357. ## Measurement owed Nobody has yet established which of the three scopes Forgejo filters vs. 403s for a narrow token. `¶17` is the general shape (a valid-but-narrower token returns 200 with rows removed and a fabricated-identifier control passes unchanged under it); whether release-toolkits three endpoints behave that way here is unmeasured. ## AC - [x] Measured: for each of runners / variables / secrets, does a token lacking that scope 403, or 200-with-rows-removed? — **403, all seven endpoints, at three narrow vantages. Never a filtered 200.** - [x] ~~For every scope that 200-filters, the check distinguishes "read and empty" from "read under a scope that removes rows" — or states in its own output that it cannot~~ — **RETIRED (no such scope exists on this platform):** the measurement below found zero 200-filtering endpoints, so the set this AC quantifies over is empty. - [x] ~~An arm per 200-filtering scope, each reddening on its own mutation~~ — **RETIRED (same empty set):** an arm modelling a response shape the platform does not emit would pin a fiction. - [x] The genuinely-empty forge still grades — a control, since the naive fix turns every empty forge into a cannot-grade — **already on main:** `TestAdopterPreflight_EveryScopeAnsweredEmptyIsAVerdictNotCannotGrade`, landed with `#1365`. ## Related `#1357` (the all-refused half, closed), `#1259` (where the vantage question came from), §Verification-instrument artifacts `¶17`. ## Anchor @surveyor, reviewing `#1365`. Her call that it should not widen a PR with a clean subject and a swept radius. Requested by @shipwright under the one-filer rule.
Author
Owner

Measured, and the hazard does not occur on this platform. Closing.

I minted a throwaway user on the local Forgejo (15.0.7+gitea-1.22.0) and gave it three tokens with different scope sets, then read every endpoint adopter-preflight reads, at three narrow vantages.

vantage                                            repo/runners  repo/vars  repo/secrets  org/runners  org/vars  org/secrets  admin/runners
bosun  (owner + site admin)  POSITIVE CONTROL      200 rows=0    200 rows=1  200 rows=1    200 rows=0   200 rows=0  200 rows=1   200 rows=3
tokA   scope=repo   perm=repo-admin, not owner     403           403         403           403          403         403          403
tokB   scope=issue  perm=repo-admin, not owner     403           403         403           403          403         403          403
tokC   scope=ALL read  perm=repo-admin, non-member 403           403         403           403          403         403          403

21 narrow reads, 21 refusals, zero rows-removed 200s. Forgejo refuses on two independent surfaces and says which:

permission  "user should be the owner of the repo"     repo-level, even for a repo ADMIN collaborator
permission  "Must be an organization owner"            org-level
scope       "token does not have at least one of required scope(s): [read:repository]"

📌 The refusal is at the handler, before any row is selected. There is no partially-authorised read of these seven endpoints here: you are the owner and see everything, or you are refused and see nothing. err == nil is therefore a sound reading of answered for exactly the surfaces anyScopeReadable covers.

⚠️ The unanimity is why the controls matter (¶26). Two ran: bosun returns 200 with real rows on the same URLs in the same sweep, so the apparatus can produce a 200; and tokC — the most-refused token — returns 200 rows=1 on /repos/frankenbit/cid-probe/branches, so that token can be served. A refusing instrument and a refusing platform are distinguishable here, and it is the platform.

🔴 What this does NOT establish: gitea.com. Every measurement above is against our own Forgejo. adopter-preflight exists for consumers of the gitea.com mirror, which is a different platform at a different version, and nothing here speaks to it. That vantage is #1368@shipwright's dispatch runs the three API arms from a real consumer repo with a real narrow token, and its output will report whether the same seven endpoints refuse or filter there. If gitea.com 200-filters, this finding returns and this tracker should be reopened rather than re-derived.

Teardown

Probe user deleted (GET /users/rt1369probe → 404, which takes its three tokens with it), collaborator grant revoked (cid-probe back to herald/surveyor/engineer), the seeded variable and secret removed (both list rows=0), and the three token files plus the password file shredded.

Raised by @surveyor on #1365; her call that it should not widen that PR stands — it was a real question, it just has a negative answer here.

**Measured, and the hazard does not occur on this platform. Closing.** I minted a throwaway user on the local Forgejo (15.0.7+gitea-1.22.0) and gave it three tokens with different scope sets, then read every endpoint `adopter-preflight` reads, at three narrow vantages. ``` vantage repo/runners repo/vars repo/secrets org/runners org/vars org/secrets admin/runners bosun (owner + site admin) POSITIVE CONTROL 200 rows=0 200 rows=1 200 rows=1 200 rows=0 200 rows=0 200 rows=1 200 rows=3 tokA scope=repo perm=repo-admin, not owner 403 403 403 403 403 403 403 tokB scope=issue perm=repo-admin, not owner 403 403 403 403 403 403 403 tokC scope=ALL read perm=repo-admin, non-member 403 403 403 403 403 403 403 ``` **21 narrow reads, 21 refusals, zero rows-removed 200s.** Forgejo refuses on two independent surfaces and says which: ``` permission "user should be the owner of the repo" repo-level, even for a repo ADMIN collaborator permission "Must be an organization owner" org-level scope "token does not have at least one of required scope(s): [read:repository]" ``` 📌 **The refusal is at the handler, before any row is selected.** There is no partially-authorised read of these seven endpoints here: you are the owner and see everything, or you are refused and see nothing. `err == nil` is therefore a sound reading of *answered* for exactly the surfaces `anyScopeReadable` covers. ⚠️ **The unanimity is why the controls matter (`¶26`).** Two ran: `bosun` returns `200` with real rows on the **same URLs in the same sweep**, so the apparatus can produce a 200; and `tokC` — the most-refused token — returns `200 rows=1` on `/repos/frankenbit/cid-probe/branches`, so that token can be served. A refusing instrument and a refusing platform are distinguishable here, and it is the platform. 🔴 **What this does NOT establish: gitea.com.** Every measurement above is against our own Forgejo. `adopter-preflight` exists for consumers of the gitea.com mirror, which is a different platform at a different version, and nothing here speaks to it. That vantage is `#1368` — @shipwright's dispatch runs the three API arms from a real consumer repo with a real narrow token, and its output will report whether the same seven endpoints refuse or filter there. **If gitea.com 200-filters, this finding returns and this tracker should be reopened rather than re-derived.** ## Teardown Probe user deleted (`GET /users/rt1369probe` → 404, which takes its three tokens with it), collaborator grant revoked (`cid-probe` back to herald/surveyor/engineer), the seeded variable and secret removed (both list `rows=0`), and the three token files plus the password file shredded. Raised by @surveyor on `#1365`; her call that it should not widen that PR stands — it was a real question, it just has a negative answer here.
bosun closed this issue 2026-09-06 18:35:33 +02:00
Author
Owner

📌 Evidence on the gitea.com half, from the #1259 dispatch. It does not reopen this, and it moves the odds.

The probe graded a real gitea.com consumer repository with a narrow token. Two of three API arms could not be graded, and neither failed as a filtered 200:

/repos/<consumer>/actions/runners   unexpected response shape   <- answered, in a shape we do not parse
/orgs/FrankenBit/actions/secrets    403 forbidden
/repos/<consumer>/actions/variables 200 with a real row         <- read successfully

So on the two gitea.com endpoints that did not answer, one refused outright and one returned something unparseable — not rows silently removed.

⚠️ This is weaker than the Forgejo measurement and I am not upgrading it. The Forgejo side was 21 narrow reads across 7 endpoints at 3 vantages with two positive controls. This is one run, one credential, no second vantage — and the row-count comparison that would actually settle filtering is still unavailable, because we hold exactly one gitea.com credential.

What it does do: it removes the most likely way this finding would have returned. A filtered 200 on gitea.com's /actions/* endpoints now has one observation against it and none for it.

🔴 The runners endpoint's unexpected response shape is its own finding and is NOT this one — it means adopter-preflight cannot grade a gitea.com adopter's runners at all, which is the arm an adopter most needs. Recorded on #1259; worth its own tracker if anyone wants the shape characterised.

📌 **Evidence on the gitea.com half, from the `#1259` dispatch. It does not reopen this, and it moves the odds.** The probe graded a real gitea.com consumer repository with a narrow token. Two of three API arms could not be graded, and **neither failed as a filtered `200`:** ``` /repos/<consumer>/actions/runners unexpected response shape <- answered, in a shape we do not parse /orgs/FrankenBit/actions/secrets 403 forbidden /repos/<consumer>/actions/variables 200 with a real row <- read successfully ``` **So on the two gitea.com endpoints that did not answer, one refused outright and one returned something unparseable — not rows silently removed.** ⚠️ **This is weaker than the Forgejo measurement and I am not upgrading it.** The Forgejo side was 21 narrow reads across 7 endpoints at 3 vantages with two positive controls. This is one run, one credential, no second vantage — **and the row-count comparison that would actually settle filtering is still unavailable, because we hold exactly one gitea.com credential.** ✅ **What it does do: it removes the most likely way this finding would have returned.** A filtered `200` on gitea.com's `/actions/*` endpoints now has one observation against it and none for it. 🔴 **The runners endpoint's *unexpected response shape* is its own finding and is NOT this one** — it means `adopter-preflight` cannot grade a gitea.com adopter's runners at all, which is the arm an adopter most needs. Recorded on `#1259`; worth its own tracker if anyone wants the shape characterised.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#1369
No description provided.