fix(web): fall back to the PAT when Gitea refuses the login token

The login token took precedence unconditionally, so a configured keyring PAT
could never help while an operator was logged in — and on this instance the
OIDC token (scopes `openid profile`) is exactly what Gitea refuses with a 403
for insufficient API scope. `ExecutionsView` now carries `refused` (401 or a
final 403) and the web layer retries once with the PAT when the token it used
was not already the PAT. A refused PAT is never sent twice, and a 401 still
gets its single refresh attempt first.
This commit is contained in:
2026-07-25 15:07:13 +02:00
parent 4aedd937d7
commit d0ea7f661d
4 changed files with 142 additions and 30 deletions
+22 -13
View File
@@ -112,6 +112,10 @@ class ExecutionsView:
``auth_failed`` distinguishes "the token was refused" (``401``) from any
other failure, so the caller can try refreshing it once before giving up.
``refused`` is the wider "this token will not work" signal (``401`` *or* a
final ``403``, i.e. insufficient scope): the web layer uses it to fall back
from the operator's login token to the configured PAT, which is the only
way a PAT can help while a scope-less login token is in play.
"""
runs: list[Execution]
@@ -119,6 +123,7 @@ class ExecutionsView:
source: str
error: str | None
auth_failed: bool = False
refused: bool = False
def _monotonic() -> float:
@@ -126,8 +131,8 @@ def _monotonic() -> float:
return time.monotonic()
# key -> (fetched_at, runs, source, error, auth_failed)
_CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool]
# key -> (fetched_at, runs, source, error, auth_failed, refused)
_CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool, bool]
_CACHE: dict[tuple[str, str, int], _CacheEntry] = {}
@@ -237,10 +242,10 @@ def _fetch(url: str, token: str) -> list[dict[str, Any]]:
def _fetch_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]:
"""Admin endpoint first, user endpoint as the non-admin fallback.
Returns ``(runs, source, error, auth_failed)``; never raises.
Returns ``(runs, source, error, auth_failed, refused)``; never raises.
"""
query = urllib.parse.urlencode({"limit": limit})
admin_url = f"{base_url}/api/v1/admin/actions/runs?{query}"
@@ -248,13 +253,15 @@ def _fetch_runs(
last_error: str | None = None
auth_failed = False
refused = False
for source, url in (("admin", admin_url), ("user", user_url)):
try:
return _fetch(url, token), source, None, False
return _fetch(url, token), source, None, False, False
except urllib.error.HTTPError as exc:
last_error = f"Gitea API returned HTTP {exc.code}."
if exc.code == 401:
auth_failed = True
refused = True
break
if exc.code in (403, 404) and source == "admin":
# Token is not admin-scoped: try the per-user listing.
@@ -264,6 +271,7 @@ def _fetch_runs(
# authenticated it and then rejected its scope. Say so --
# "HTTP 403" alone sends the operator hunting the wrong thing.
last_error = _SCOPE_HINT
refused = True
break
except (urllib.error.URLError, TimeoutError, OSError) as exc:
last_error = f"Gitea unreachable: {exc}."
@@ -273,23 +281,23 @@ def _fetch_runs(
break
logger.warning("executions fetch failed: %s", last_error)
return [], "none", last_error, auth_failed
return [], "none", last_error, auth_failed, refused
def _cached_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]:
""":func:`_fetch_runs` behind the TTL cache (errors are cached too)."""
key = (base_url, token, limit)
now = _monotonic()
entry = _CACHE.get(key)
if entry is not None and now - entry[0] < _TTL_SEC:
_fetched_at, runs, source, error, auth_failed = entry
return runs, source, error, auth_failed
_fetched_at, runs, source, error, auth_failed, refused = entry
return runs, source, error, auth_failed, refused
runs, source, error, auth_failed = _fetch_runs(base_url, token, limit)
_CACHE[key] = (now, runs, source, error, auth_failed)
return runs, source, error, auth_failed
runs, source, error, auth_failed, refused = _fetch_runs(base_url, token, limit)
_CACHE[key] = (now, runs, source, error, auth_failed, refused)
return runs, source, error, auth_failed, refused
def _attach_vms(
@@ -365,7 +373,7 @@ def list_executions(
error="Gitea token not configured.",
)
raw_runs, source, error, auth_failed = _cached_runs(
raw_runs, source, error, auth_failed, refused = _cached_runs(
base_url.rstrip("/"), token, effective_limit
)
if error is not None:
@@ -375,6 +383,7 @@ def list_executions(
source=source,
error=error,
auth_failed=auth_failed,
refused=refused,
)
runs, unmatched = _attach_vms(