From 7c41b798c1a3a17ce4b6aa6c8279056a40c931a2 Mon Sep 17 00:00:00 2001 From: Adarsha Dinda Date: Mon, 9 Feb 2026 07:51:59 +0000 Subject: [PATCH] feat(auth): add device flow and token auth for headless/CI Signed-off-by: Adarsha Dinda --- README.md | 28 ++++- pytest.ini | 1 + setup.cfg | 1 + src/teuthology_api/routes/login.py | 118 +++++++++++++++++- src/teuthology_api/routes/suite.py | 42 ++++++- src/teuthology_api/services/helpers.py | 165 ++++++++++++++++++++----- src/teuthology_api/services/kill.py | 2 +- src/teuthology_api/services/suite.py | 7 +- tests/test_helpers.py | 38 ++++-- tests/test_kill.py | 5 +- tests/test_suite.py | 14 ++- 11 files changed, 359 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 8203a02..d303b87 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,34 @@ A REST API to execute [teuthology commands](https://docs.ceph.com/projects/teuth The documentation can be accessed at http://localhost:8082/docs after running the application. -Once you have teuthology-api running, authenticate by visiting `http://localhost:8082/login` through browser and follow the github authentication steps (this stores the auth token in browser cookies). +Once you have teuthology-api running, authenticate by visiting `http://localhost:8082/login` through browser and follow the github authentication steps (this stores the auth token in browser cookies). For headless/CLI/CI use, see **Device code API** below. > Note: To test below endpoints locally, recommended flow is to login through browser (as mentioned above) and then send requests (and receive response) through interactive docs at `/docs`. +### Device code API (headless / CLI / CI) + +For scripts, CI, or environments without a browser, use the GitHub device flow to obtain an access token. + +1. **Start device flow** — get a user code and verification URL: + + ```bash + curl -X POST http://localhost:8082/login/device/code + ``` + + Response example: `{"device_code": "...", "user_code": "ABCD-1234", "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 5}`. + +2. **Have the user authorize** — open `verification_uri` in a browser, enter `user_code`, and approve access. + +3. **Poll for token** — call the token endpoint with the `device_code` from step 1 (query param `device_code`): + + ```bash + curl -X POST "http://localhost:8082/login/device/token?device_code=YOUR_DEVICE_CODE" + ``` + + - **202** = authorization pending (poll again after `interval` seconds). + - **200** = success; response includes `access_token`. Use it in `X-Access-Token` or `Authorization: Bearer ` for other API calls. + - **403** = user not in Ceph org or access denied. **410** = code expired. + ### Route `/` ``` @@ -109,5 +133,3 @@ Example }' Note: "--owner" in data body should be same as your github username (case sensitive). Otherwise, you wouldn't have permission to kill jobs/run. - -xxx diff --git a/pytest.ini b/pytest.ini index b478d07..14bf7d9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,6 @@ [pytest] pythonpath = src +asyncio_mode = auto log_cli = 1 log_cli_level = INFO log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s) diff --git a/setup.cfg b/setup.cfg index 1304513..5922409 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,6 +73,7 @@ testing = setuptools pytest pytest-cov + pytest-asyncio [options.entry_points] # Add here console scripts like: diff --git a/src/teuthology_api/routes/login.py b/src/teuthology_api/routes/login.py index e318882..38f4279 100644 --- a/src/teuthology_api/routes/login.py +++ b/src/teuthology_api/routes/login.py @@ -5,17 +5,21 @@ from dotenv import load_dotenv import httpx -from teuthology_api.services.helpers import isAdmin - load_dotenv() GH_CLIENT_ID = os.getenv("GH_CLIENT_ID") GH_CLIENT_SECRET = os.getenv("GH_CLIENT_SECRET") GH_AUTHORIZATION_BASE_URL = os.getenv("GH_AUTHORIZATION_BASE_URL") GH_TOKEN_URL = os.getenv("GH_TOKEN_URL") +GH_DEVICE_CODE_URL = os.getenv( + "GH_DEVICE_CODE_URL", "https://github.com/login/device/code" +) GH_FETCH_MEMBERSHIP_URL = os.getenv("GH_FETCH_MEMBERSHIP_URL") PULPITO_URL = os.getenv("PULPITO_URL") +# Scope for device flow (must include read:org for Ceph org membership check) +DEVICE_FLOW_SCOPE = "read:org" + log = logging.getLogger(__name__) router = APIRouter( prefix="/login", @@ -88,6 +92,7 @@ async def handle_callback(code: str, request: Request): "access_token": token, } request.session["user"] = data + from teuthology_api.services.helpers import isAdmin isUserAdmin = await isAdmin(data["username"], data["access_token"]) data["isUserAdmin"] = isUserAdmin cookie_data = { @@ -101,3 +106,112 @@ async def handle_callback(code: str, request: Request): response = RedirectResponse(PULPITO_URL) response.set_cookie(key="GH_USER", value=cookie) return response + + +# --- Device flow (headless / CLI / scripts) --- + + +@router.post("/device/code", status_code=200) +async def device_code(): + """ + Start GitHub device flow. Returns device_code, user_code, verification_uri, + expires_in, and interval. The client should show the user the verification_uri + and user_code, then poll POST /login/device/token with the device_code until + the user authorizes or the code expires. + """ + if not GH_CLIENT_ID: + raise HTTPException( + status_code=500, detail="Environment secrets are missing (GH_CLIENT_ID)." + ) + headers = {"Accept": "application/json"} + data = {"client_id": GH_CLIENT_ID, "scope": DEVICE_FLOW_SCOPE} + async with httpx.AsyncClient() as client: + response = await client.post( + url=GH_DEVICE_CODE_URL, data=data, headers=headers + ) + if response.status_code != 200: + log.error("Device code request failed: %s", response.text) + raise HTTPException( + status_code=response.status_code, + detail=response.text or "Failed to get device code", + ) + result = response.json() + if "error" in result: + log.error("Device code error: %s", result) + raise HTTPException( + status_code=400, + detail=result.get("error_description", result.get("error", "Unknown error")), + ) + return { + "device_code": result.get("device_code"), + "user_code": result.get("user_code"), + "verification_uri": result.get("verification_uri"), + "expires_in": result.get("expires_in"), + "interval": result.get("interval", 5), + } + + +@router.post("/device/token", status_code=200) +async def device_token(device_code: str): + """ + Poll during device flow. Pass the device_code from POST /login/device/code. + Returns access_token when the user has authorized; otherwise raises with + authorization_pending, slow_down, expired_token, or access_denied. + """ + if not GH_CLIENT_ID: + raise HTTPException( + status_code=500, detail="Environment secrets are missing (GH_CLIENT_ID)." + ) + headers = {"Accept": "application/json"} + data = { + "client_id": GH_CLIENT_ID, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + async with httpx.AsyncClient() as client: + response = await client.post(url=GH_TOKEN_URL, data=data, headers=headers) + result = response.json() + + if "error" in result: + error = result.get("error") + desc = result.get("error_description", error) + if error == "authorization_pending": + raise HTTPException(status_code=202, detail=desc) + if error == "slow_down": + raise HTTPException(status_code=429, detail=desc) + if error in ("expired_token", "token_expired"): + raise HTTPException(status_code=410, detail=desc) + if error == "access_denied": + raise HTTPException(status_code=403, detail=desc) + if error == "incorrect_device_code": + raise HTTPException(status_code=400, detail=desc) + raise HTTPException(status_code=400, detail=desc) + + token = result.get("access_token") + if not token: + raise HTTPException(status_code=500, detail="No access_token in response") + + # Validate user is in Ceph org (same as web callback) + headers = {"Authorization": "token " + token, "Accept": "application/json"} + async with httpx.AsyncClient() as client: + response_org = await client.get( + url=GH_FETCH_MEMBERSHIP_URL, headers=headers + ) + if response_org.status_code == 404: + log.error("User is not part of the Ceph Organization") + raise HTTPException( + status_code=403, + detail="User is not part of the Ceph Organization, please contact admin", + ) + if response_org.status_code != 200: + log.error("Membership check failed: %s", response_org.text) + raise HTTPException( + status_code=response_org.status_code, + detail="Failed to verify organization membership", + ) + + return { + "access_token": token, + "token_type": result.get("token_type", "bearer"), + "scope": result.get("scope"), + } diff --git a/src/teuthology_api/routes/suite.py b/src/teuthology_api/routes/suite.py index 13f1905..471116a 100644 --- a/src/teuthology_api/routes/suite.py +++ b/src/teuthology_api/routes/suite.py @@ -1,9 +1,10 @@ import logging +from typing import Optional -from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi import APIRouter, HTTPException, Header, Request from teuthology_api.services.suite import run -from teuthology_api.services.helpers import get_token, get_username +from teuthology_api.services.helpers import resolve_access_token from teuthology_api.schemas.suite import SuiteArgs log = logging.getLogger(__name__) @@ -16,12 +17,41 @@ @router.post("/", status_code=200) -def create_run( +async def create_run( request: Request, args: SuiteArgs, - access_token: str = Depends(get_token), logs: bool = False, + access_token: Optional[str] = Header( + default=None, + alias="X-Access-Token", + description="GitHub access token for headless auth (alternative to Authorization header)", + ), ): + """ + Schedule a teuthology suite run. + + Authentication options (in order of precedence): + 1. Authorization: Bearer header (or X-Access-Token header) + 2. Session cookie - browser-based login + + For headless/CI usage, obtain a token via the device flow: + - POST /login/device/code -> get user_code and verification_uri + - User visits verification_uri and enters user_code + - POST /login/device/token -> poll until access_token is returned + - Use the access_token in X-Access-Token header or Authorization: Bearer header + """ + # Resolve token and username from various auth sources (never use body for --user) + username, token_dict = await resolve_access_token(request, access_token) + args = args.model_dump(by_alias=True) - args["--user"] = get_username(request) - return run(args, logs, access_token) + # Force both from auth: --user => run name + subprocess USER; --owner => scheduler ownership (kill permission) + args["--user"] = username + args["--owner"] = username + log.info("Scheduling suite as user: %s", username) + try: + created_run = run(args, logs, token_dict) + log.debug(created_run) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + return created_run + diff --git a/src/teuthology_api/services/helpers.py b/src/teuthology_api/services/helpers.py index 6e4ebeb..62625d1 100644 --- a/src/teuthology_api/services/helpers.py +++ b/src/teuthology_api/services/helpers.py @@ -8,8 +8,7 @@ from fastapi import HTTPException, Request from dotenv import load_dotenv -import teuthology -import requests # Note: import requests after teuthology +import requests from requests.exceptions import HTTPError load_dotenv() @@ -20,37 +19,59 @@ ADMIN_TEAM = os.getenv("ADMIN_TEAM") GH_ORG_TEAM_URL = os.getenv("GH_ORG_TEAM_URL") +GH_FETCH_MEMBERSHIP_URL = os.getenv("GH_FETCH_MEMBERSHIP_URL") +GH_USER_URL = "https://api.github.com/user" log = logging.getLogger(__name__) def logs_run(func, args): """ - Run the command function in a seperate process (to isolate logs), + Run the command function in a separate process (to isolate logs), and return logs printed during the execution of the function. + Teuthology is imported in the subprocess only to avoid gevent in API workers. """ _id = str(uuid.uuid4()) archive = Path(ARCHIVE_DIR) log_file = archive / f"{_id}.log" - - teuthology_process = Process(target=_execute_with_logs, args=(func, args, log_file)) - teuthology_process.start() - teuthology_process.join() - + teuth_process = Process( + target=_execute_with_logs, args=(func, args, log_file) + ) + teuth_process.start() + teuth_process.join() logs = "" with open(log_file, encoding="utf-8") as file: logs = file.readlines() if os.path.isfile(log_file): os.remove(log_file) + if teuth_process.exitcode != 0: + log.error("Teuthology process exited with code %s", teuth_process.exitcode) + raise RuntimeError( + "Teuthology process failed (exit code %s)" % teuth_process.exitcode + ) return logs def _execute_with_logs(func, args, log_file): """ - To store logs, set a new FileHandler for teuthology root logger - and then execute the command function. + Run in subprocess: set teuthology log file and execute the command function. + Teuthology is imported here to avoid gevent monkey-patching in the main API. """ + import gevent.monkey # noqa: E402 - before teuthology + _orig_patch_all = gevent.monkey.patch_all + + def _patch_all_ssl_off(**kwargs): + kwargs["ssl"] = False + return _orig_patch_all(**kwargs) + + gevent.monkey.patch_all = _patch_all_ssl_off + import teuthology # noqa: E402 teuthology.setup_log_file(log_file) + if isinstance(func, str): + import importlib + mod_path, _, attr = func.rpartition(".") + mod = importlib.import_module(mod_path) + func = getattr(mod, attr) func(args) @@ -73,34 +94,122 @@ def get_run_details(run_name: str): raise HTTPException(status_code=500, detail=str(err)) from err -def get_username(request: Request): +async def _validate_token_with_github(token: str): + """ + Validate a GitHub access token and return (username, token_dict). + Checks token validity and Ceph org membership. """ - Get username from request.session + if not GH_FETCH_MEMBERSHIP_URL: + raise HTTPException( + status_code=500, + detail="GH_FETCH_MEMBERSHIP_URL is not configured", + ) + async with httpx.AsyncClient() as client: + headers = {"Authorization": "token " + token, "Accept": "application/json"} + user_resp = await client.get(url=GH_USER_URL, headers=headers) + if user_resp.status_code != 200: + log.error("Token validation failed: %s", user_resp.text) + raise HTTPException( + status_code=401, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + user_data = user_resp.json() + username = user_data.get("login") + if not username: + raise HTTPException( + status_code=401, + detail="Could not get username from token", + headers={"WWW-Authenticate": "Bearer"}, + ) + # Verify Ceph org membership + membership_resp = await client.get( + url=GH_FETCH_MEMBERSHIP_URL, headers=headers + ) + if membership_resp.status_code == 404: + log.error("User %s is not part of the Ceph Organization", username) + raise HTTPException( + status_code=403, + detail="User is not part of the Ceph Organization", + ) + if membership_resp.status_code != 200: + log.error("Membership check failed: %s", membership_resp.text) + raise HTTPException( + status_code=401, + detail="Failed to verify organization membership", + headers={"WWW-Authenticate": "Bearer"}, + ) + return username, {"access_token": token, "token_type": "bearer"} + + +async def resolve_access_token(request: Request, direct_token: str = None): + """ + Resolve access token from multiple sources (in order of precedence): + 1. Direct token parameter (X-Access-Token header for headless auth) + 2. Authorization: Bearer header + 3. Session cookie (browser login) + + Returns (username, token_dict). Validates tokens against GitHub. """ - username = request.session.get("user", {}).get("username") - if username: - return username - log.error("username empty, user probably is not logged in.") + # 1. Check for direct token (X-Access-Token header) + if direct_token is not None: + token = (direct_token or "").strip() + if token: + log.info("Using X-Access-Token header for auth") + username, token_dict = await _validate_token_with_github(token) + log.info("Resolved username from token: %s", username) + return username, token_dict + + # 2. Check for Bearer token (Authorization header) + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + if token: + log.info("Using Authorization: Bearer for auth") + username, token_dict = await _validate_token_with_github(token) + log.info("Resolved username from token: %s", username) + return username, token_dict + + # 3. Fall back to session (browser login) + user = request.session.get("user", {}) or {} + username = user.get("username") + token = user.get("access_token") + if username and token: + log.info("Using session for auth; username: %s", username) + return username, {"access_token": token, "token_type": "bearer"} + + log.error("No valid auth: missing session, Bearer token, or X-Access-Token.") raise HTTPException( status_code=401, - detail="You need to be logged in", + detail="You need to be logged in. Use X-Access-Token header, Authorization: Bearer header, or browser login.", headers={"WWW-Authenticate": "Bearer"}, ) -def get_token(request: Request): +async def _resolve_auth(request: Request): """ - Get access token from request.session + Resolve auth from session or Authorization: Bearer header. + Returns (username, token_dict). Validates Bearer tokens against GitHub. + + Deprecated: Use resolve_access_token() instead for new code. """ - token = request.session.get("user", {}).get("access_token") - if token: - return {"access_token": token, "token_type": "bearer"} - log.error("access_token empty, user probably is not logged in.") - raise HTTPException( - status_code=401, - detail="You need to be logged in", - headers={"WWW-Authenticate": "Bearer"}, - ) + return await resolve_access_token(request) + + +async def get_username(request: Request): + """ + Get username from session or Authorization: Bearer header. + """ + username, _ = await _resolve_auth(request) + return username + + +async def get_token(request: Request): + """ + Get access token from session or Authorization: Bearer header. + """ + _, token_dict = await _resolve_auth(request) + return token_dict async def isAdmin(username, token): diff --git a/src/teuthology_api/services/kill.py b/src/teuthology_api/services/kill.py index 5252ac0..2725281 100644 --- a/src/teuthology_api/services/kill.py +++ b/src/teuthology_api/services/kill.py @@ -24,7 +24,7 @@ async def run(args, send_logs: bool, token: dict, request: Request): detail="You need to be logged in", headers={"WWW-Authenticate": "Bearer"}, ) - username = get_username(request) + username = await get_username(request) run_name = args.get("--run", "") if run_name: run_details = get_run_details(run_name) diff --git a/src/teuthology_api/services/suite.py b/src/teuthology_api/services/suite.py index 956f344..ba94933 100644 --- a/src/teuthology_api/services/suite.py +++ b/src/teuthology_api/services/suite.py @@ -1,6 +1,5 @@ from datetime import datetime import logging -import teuthology.suite from fastapi import HTTPException @@ -9,12 +8,12 @@ log = logging.getLogger(__name__) -def run(args, send_logs: bool, access_token: str): +def run(args, send_logs: bool, token_dict): """ Schedule a suite. :returns: Run details (dict) and logs (list). """ - if not access_token: + if not token_dict: raise HTTPException( status_code=401, detail="You need to be logged in", @@ -23,7 +22,7 @@ def run(args, send_logs: bool, access_token: str): try: args["--timestamp"] = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") - logs = logs_run(teuthology.suite.main, args) + logs = logs_run("teuthology.suite.main", args) # get run details from paddles run_name = make_run_name( diff --git a/tests/test_helpers.py b/tests/test_helpers.py index cda9a64..c41562f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -7,9 +7,19 @@ client = TestClient(app) +# Expected 401 detail when no auth (matches helpers.resolve_access_token) +AUTH_REQUIRED_DETAIL = ( + "You need to be logged in. Use X-Access-Token header, " + "Authorization: Bearer header, or browser login." +) + class MockRequest: + """Minimal request-like object for resolve_access_token (session + headers).""" + def __init__(self, access_token="testToken123", bad=False): + # resolve_access_token checks headers first, then session + self.headers = {} # no Authorization / X-Access-Token, so it falls back to session if bad: self.session = {} else: @@ -21,37 +31,41 @@ def __init__(self, access_token="testToken123", bad=False): } -# get_token +# get_token (async) @patch("teuthology_api.services.helpers.Request") -def test_get_token_success(m_request): +@pytest.mark.asyncio +async def test_get_token_success(m_request): m_request = MockRequest() expected = {"access_token": "testToken123", "token_type": "bearer"} - actual = get_token(m_request) + actual = await get_token(m_request) assert expected == actual @patch("teuthology_api.services.helpers.Request") -def test_get_token_fail(m_request): +@pytest.mark.asyncio +async def test_get_token_fail(m_request): with pytest.raises(HTTPException) as err: m_request = MockRequest(bad=True) - get_token(m_request) + await get_token(m_request) assert err.value.status_code == 401 - assert err.value.detail == "You need to be logged in" + assert err.value.detail == AUTH_REQUIRED_DETAIL -# get username +# get_username (async) @patch("teuthology_api.services.helpers.Request") -def test_get_username_success(m_request): +@pytest.mark.asyncio +async def test_get_username_success(m_request): m_request = MockRequest() expected = "user1" - actual = get_username(m_request) + actual = await get_username(m_request) assert expected == actual @patch("teuthology_api.services.helpers.Request") -def test_get_username_fail(m_request): +@pytest.mark.asyncio +async def test_get_username_fail(m_request): with pytest.raises(HTTPException) as err: m_request = MockRequest(bad=True) - get_username(m_request) + await get_username(m_request) assert err.value.status_code == 401 - assert err.value.detail == "You need to be logged in" + assert err.value.detail == AUTH_REQUIRED_DETAIL diff --git a/tests/test_kill.py b/tests/test_kill.py index 71ad49a..d2ec385 100644 --- a/tests/test_kill.py +++ b/tests/test_kill.py @@ -53,7 +53,10 @@ def test_kill_run_success(m_get_username, m_get_run_details, m_popen, m_isAdmin) def test_kill_run_fail(): response = client.post("/kill", data=json.dumps(mock_kill_args)) assert response.status_code == 401 - assert response.json() == {"detail": "You need to be logged in"} + assert response.json()["detail"] == ( + "You need to be logged in. Use X-Access-Token header, " + "Authorization: Bearer header, or browser login." + ) @patch("teuthology_api.services.kill.isAdmin") diff --git a/tests/test_suite.py b/tests/test_suite.py index ab2f6f7..d8be10b 100644 --- a/tests/test_suite.py +++ b/tests/test_suite.py @@ -28,14 +28,18 @@ async def override_get_token(): "--machine-type": "testnode", } -# suite +# suite (route uses resolve_access_token, not get_username) @patch("teuthology_api.services.suite.logs_run") -@patch("teuthology_api.routes.suite.get_username") +@patch("teuthology_api.routes.suite.resolve_access_token") @patch("teuthology_api.services.suite.get_run_details") -def test_suite_run_success(m_get_run_details, m_get_username, m_logs_run): - m_get_username.return_value = "user1" +def test_suite_run_success(m_get_run_details, m_resolve_access_token, m_logs_run): + async def _resolve(*args, **kwargs): + return ("user1", {"access_token": "token_123", "token_type": "bearer"}) + + m_resolve_access_token.side_effect = _resolve + m_logs_run.return_value = [] m_get_run_details.return_value = {"id": "7451978", "user": "user1"} - response = client.post("/suite", data=json.dumps(mock_suite_args)) + response = client.post("/suite/", data=json.dumps(mock_suite_args)) assert response.status_code == 200 assert response.json() == {"run": {"id": "7451978", "user": "user1"}}