diff --git a/server/auth.py b/server/auth.py index 47a5cfb34..a9a808eed 100644 --- a/server/auth.py +++ b/server/auth.py @@ -184,3 +184,27 @@ async def require_auth( return default_user raise HTTPException(status_code=401, detail="Authentication required.") return user + + +def _ensure_admin(request: Request, user: User | None) -> None: + """Single source of truth for admin gating. + + Allows: legacy ADMIN_API_KEY env (X-API-Key header), AUTH_DISABLED=true, + or a registered User with role == 'admin'. Raises 403 otherwise. + + Pure function — reusable from FastAPI deps and inline route guards. + """ + auth_type = getattr(request.state, "auth_type", "none") + if auth_type in {"admin_api_key", "disabled"}: + return + if user is not None and user.role == "admin": + return + raise HTTPException(status_code=403, detail="Admin role required.") + + +async def require_admin( + request: Request, + user: User | None = Depends(verify_auth), +) -> None: + """FastAPI dependency. Admin-only gate; returns nothing.""" + _ensure_admin(request, user) diff --git a/server/tests/test_auth_helpers.py b/server/tests/test_auth_helpers.py new file mode 100644 index 000000000..1388dc316 --- /dev/null +++ b/server/tests/test_auth_helpers.py @@ -0,0 +1,56 @@ +"""Unit tests for _ensure_admin and require_admin in server/auth.py.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + + +def _fake_request(auth_type: str = "none"): + return SimpleNamespace(state=SimpleNamespace(auth_type=auth_type)) + + +def test_ensure_admin_passes_for_admin_user(admin_user): + from auth import _ensure_admin + + _ensure_admin(_fake_request("bearer"), admin_user) # must not raise + + +def test_ensure_admin_passes_for_admin_api_key_auth_type(): + from auth import _ensure_admin + + _ensure_admin(_fake_request("admin_api_key"), None) # must not raise + + +def test_ensure_admin_passes_for_auth_disabled(): + from auth import _ensure_admin + + _ensure_admin(_fake_request("disabled"), None) # must not raise + + +def test_ensure_admin_rejects_non_admin_user(member_user): + from auth import _ensure_admin + + with pytest.raises(HTTPException) as exc: + _ensure_admin(_fake_request("bearer"), member_user) + assert exc.value.status_code == 403 + assert "Admin role required" in exc.value.detail + + +def test_ensure_admin_rejects_no_user_with_no_legacy_path(): + from auth import _ensure_admin + + with pytest.raises(HTTPException) as exc: + _ensure_admin(_fake_request("none"), None) + assert exc.value.status_code == 403 + + +def test_ensure_admin_rejects_bearer_with_no_user(): + """Defense-in-depth: a 'bearer' auth_type without a User should not pass.""" + from auth import _ensure_admin + + with pytest.raises(HTTPException) as exc: + _ensure_admin(_fake_request("bearer"), None) + assert exc.value.status_code == 403