Skip to content

Commit 56a01ad

Browse files
committed
Add API token validation at startup
We need to validate at startup if we have all needed permission. This is additional validation to reduce headache of troubleshooting, if there is any mistake in permissions and node is not being updated. Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent 23d22c5 commit 56a01ad

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

src/kernel_ci_cloud_labs/pull_labs_poller.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,80 @@ def _http_put_json(
141141
return json.loads(resp_body) if resp_body else None
142142

143143

144+
def _validate_api_token(
145+
api_base_uri: str, api_token: Optional[str], runtime_name: str
146+
) -> None:
147+
"""Startup preflight: confirm api_token authenticates and can edit nodes.
148+
149+
Calls GET /whoami once and logs the outcome. Never fatal -- a transient
150+
API error must not stop the poller from starting, and node updates have
151+
their own per-call error handling. It surfaces, at startup, the two
152+
failure modes that otherwise only show up as a 401 on every job:
153+
* the token does not authenticate at all (no token / invalid / expired);
154+
* the token authenticates but the user cannot edit job nodes for this
155+
runtime (kernelci-api _user_can_edit_node).
156+
"""
157+
if not api_token:
158+
logger.warning(
159+
"No kernelci-api token set (KERNELCI_API_TOKEN / UNIFIED_TOKEN / "
160+
"config kernelci.api_token) -- node claim/finish updates will "
161+
"fail with HTTP 401"
162+
)
163+
return
164+
165+
url = f"{api_base_uri.rstrip('/')}/whoami"
166+
try:
167+
whoami = _http_get_json(url, token=api_token) or {}
168+
except urllib.error.HTTPError as e:
169+
if e.code in (401, 403):
170+
logger.error(
171+
"kernelci-api token rejected by %s (HTTP %s) -- the token is "
172+
"invalid, expired, or not a kernelci-api token; node updates "
173+
"will fail",
174+
url, e.code,
175+
)
176+
else:
177+
logger.warning(
178+
"Could not validate kernelci-api token via %s: HTTP %s",
179+
url, e.code,
180+
)
181+
return
182+
except (urllib.error.URLError, json.JSONDecodeError) as e:
183+
logger.warning(
184+
"Could not reach %s to validate the kernelci-api token (%s) -- "
185+
"continuing; node updates will be retried per job",
186+
url, e,
187+
)
188+
return
189+
190+
username = whoami.get("username") or whoami.get("email") or "<unknown>"
191+
is_superuser = bool(whoami.get("is_superuser"))
192+
groups = {
193+
g.get("name")
194+
for g in whoami.get("groups", [])
195+
if isinstance(g, dict) and g.get("name")
196+
}
197+
# Groups that let a user edit a job node it does not own
198+
# (kernelci-api _user_can_edit_node).
199+
editor_groups = {
200+
"node:edit:any",
201+
f"runtime:{runtime_name}:node-editor",
202+
f"runtime:{runtime_name}:node-admin",
203+
}
204+
logger.info(
205+
"kernelci-api token OK: user=%s superuser=%s groups=%s",
206+
username, is_superuser, sorted(groups) or [],
207+
)
208+
if not is_superuser and not (groups & editor_groups):
209+
logger.warning(
210+
"kernelci-api user %s cannot edit job nodes for runtime '%s': "
211+
"not a superuser and in none of %s -- node claim/finish updates "
212+
"will fail with HTTP 401 unless the user owns the nodes. Add the "
213+
"user to group 'runtime:%s:node-editor'.",
214+
username, runtime_name, sorted(editor_groups), runtime_name,
215+
)
216+
217+
144218
# ---------------------------------------------------------------------------
145219
# Cursor persistence — generic filesystem backend by default.
146220
# A deployment can swap in a custom CursorStore (e.g. backed by S3) by
@@ -392,6 +466,10 @@ def __init__(
392466
if job_executor is None:
393467
_validate_default_executor_deps()
394468

469+
# Startup preflight: surface a bad/under-privileged kernelci-api token
470+
# now, rather than as a 401 on every job's claim/finish update.
471+
_validate_api_token(self.api_base_uri, self.api_token, self.runtime_name)
472+
395473
# -- Credential resolution -------------------------------------------
396474

397475
@staticmethod

tests/test_pull_labs_poller.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""Unit tests for pull_labs_poller (no network, no AWS)."""
77

88
import json
9+
import logging
910
import os
1011
import tempfile
1112
import urllib.error
@@ -28,9 +29,10 @@
2829
_GET = "kernel_ci_cloud_labs.pull_labs_poller._http_get_json"
2930
_PUT = "kernel_ci_cloud_labs.pull_labs_poller._http_put_json"
3031

31-
# Capture the real validator at import time so a specific test can restore it
32-
# after the autouse fixture has stubbed it out.
32+
# Capture the real validators at import time so a specific test can call them
33+
# after the autouse fixtures have stubbed them out.
3334
_REAL_VALIDATE_DEFAULT_EXECUTOR_DEPS = poller_mod._validate_default_executor_deps
35+
_REAL_VALIDATE_API_TOKEN = poller_mod._validate_api_token
3436

3537

3638
# ---------------------------------------------------------------------------
@@ -60,6 +62,16 @@ def _skip_default_executor_deps_check(monkeypatch):
6062
monkeypatch.setattr(poller_mod, "_validate_default_executor_deps", lambda: None)
6163

6264

65+
@pytest.fixture(autouse=True)
66+
def _skip_api_token_check(monkeypatch):
67+
"""Bypass the startup /whoami token preflight (no network in unit tests).
68+
69+
Dedicated tests call the real _validate_api_token via the captured
70+
reference with _http_get_json patched.
71+
"""
72+
monkeypatch.setattr(poller_mod, "_validate_api_token", lambda *a, **k: None)
73+
74+
6375
def _minimal_kc(**overrides):
6476
base = {
6577
"api_base_uri": "https://api.example/latest",
@@ -554,3 +566,55 @@ def _fail_if_called():
554566
# Custom executor — validator must be skipped, no SystemExit.
555567
PullLabsPoller(_minimal_kc(), job_executor=lambda cfg: ([], None))
556568
assert called["validator"] is False
569+
570+
571+
# ---------------------------------------------------------------------------
572+
# Startup /whoami token preflight
573+
# ---------------------------------------------------------------------------
574+
575+
576+
class TestValidateApiToken:
577+
"""_validate_api_token() -- never fatal, logs token validity and groups."""
578+
579+
URI = "https://api.example/latest"
580+
RUNTIME = "pull-labs-aws-ec2"
581+
582+
def test_no_token_warns(self, caplog):
583+
with caplog.at_level(logging.WARNING):
584+
_REAL_VALIDATE_API_TOKEN(self.URI, None, self.RUNTIME)
585+
assert "No kernelci-api token" in caplog.text
586+
587+
def test_401_logs_error(self, caplog):
588+
err = urllib.error.HTTPError(self.URI, 401, "Unauthorized", {}, None)
589+
with patch(_GET, side_effect=err), caplog.at_level(logging.ERROR):
590+
_REAL_VALIDATE_API_TOKEN(self.URI, "bad-token", self.RUNTIME)
591+
assert "rejected" in caplog.text
592+
593+
def test_network_error_is_not_fatal(self):
594+
# A transient API error must not raise -- it cannot block startup.
595+
with patch(_GET, side_effect=urllib.error.URLError("boom")):
596+
_REAL_VALIDATE_API_TOKEN(self.URI, "t", self.RUNTIME)
597+
598+
def test_valid_token_with_editor_group(self, caplog):
599+
whoami = {
600+
"username": "pullbot",
601+
"groups": [{"name": "runtime:pull-labs-aws-ec2:node-editor"}],
602+
}
603+
with patch(_GET, return_value=whoami), caplog.at_level(logging.INFO):
604+
_REAL_VALIDATE_API_TOKEN(self.URI, "t", self.RUNTIME)
605+
assert "token OK" in caplog.text
606+
assert "cannot edit" not in caplog.text
607+
608+
def test_superuser_token_ok(self, caplog):
609+
whoami = {"username": "root", "is_superuser": True, "groups": []}
610+
with patch(_GET, return_value=whoami), caplog.at_level(logging.INFO):
611+
_REAL_VALIDATE_API_TOKEN(self.URI, "t", self.RUNTIME)
612+
assert "cannot edit" not in caplog.text
613+
614+
def test_valid_token_without_editor_group_warns(self, caplog):
615+
whoami = {"username": "pullbot", "groups": [{"name": "some-other-group"}]}
616+
with patch(_GET, return_value=whoami), caplog.at_level(logging.WARNING):
617+
_REAL_VALIDATE_API_TOKEN(self.URI, "t", self.RUNTIME)
618+
assert "cannot edit job nodes" in caplog.text
619+
# The required group is named in the hint.
620+
assert "runtime:pull-labs-aws-ec2:node-editor" in caplog.text

0 commit comments

Comments
 (0)