Skip to content

Commit 7fe71cd

Browse files
Fix the metrics endpoint for SSO auth
1 parent 1bec036 commit 7fe71cd

2 files changed

Lines changed: 106 additions & 10 deletions

File tree

operations-manager/python/opi/middleware/authorization.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from starlette.middleware.base import BaseHTTPMiddleware
1313
from starlette.requests import Request
14-
from starlette.responses import RedirectResponse, Response
14+
from starlette.responses import JSONResponse, RedirectResponse, Response
1515
from starlette.routing import Match
1616

1717
from opi.services.user_service import get_user_service
@@ -21,10 +21,14 @@
2121

2222
logger = logging.getLogger(__name__)
2323

24-
# URL prefixes that skip authorization checks and logging (health checks, metrics, etc.)
24+
# Exact paths that skip authorization checks. These MUST be exact matches and not
25+
# prefixes, otherwise they would also match longer authenticated routes (e.g. the
26+
# "/metrics" Prometheus scrape endpoint must not match the "/metrics-explorer" page).
27+
SKIP_AUTH_EXACT = ("/metrics",)
28+
29+
# URL prefixes that skip authorization checks and logging (health checks, static assets).
2530
SKIP_AUTH_PREFIXES = (
2631
"/health",
27-
"/metrics",
2832
"/ready",
2933
"/static/",
3034
)
@@ -79,13 +83,23 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
7983
path = request.url.path
8084

8185
# Skip auth checks and logging for infrastructure endpoints
82-
if path.startswith(SKIP_AUTH_PREFIXES):
86+
if path in SKIP_AUTH_EXACT or path.startswith(SKIP_AUTH_PREFIXES):
8387
return await call_next(request)
8488

85-
# API routes should use API key authentication, not SSO by default
89+
# API routes use API key authentication by default, but honor an explicit
90+
# @requires_sso marking (e.g. browser-called endpoints like metrics-explorer).
91+
# These get the same session gate as web pages, but with JSON responses.
8692
if path.startswith("/api/"):
87-
# For API routes, only require SSO if explicitly marked with @requires_sso
88-
request.state.user = None # API routes don't use session-based user
93+
user = get_user(request)
94+
request.state.user = user
95+
if self._route_requires_sso(request):
96+
if not user:
97+
logger.info(f"Rejecting unauthenticated SSO-required API request: {path}")
98+
return JSONResponse(status_code=401, content={"error": "Authentication required"})
99+
user_email = user.get("email")
100+
if user_email and not get_user_service().is_email_allowed(user_email):
101+
logger.warning(f"Access denied for user {user_email} on {path} - not in allowlist")
102+
return JSONResponse(status_code=403, content={"error": "Access denied"})
89103
return await call_next(request)
90104

91105
# Get user from session

operations-manager/python/tests/test_authorization.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,100 @@ async def test_static_files_always_pass(self):
8080
call_next.assert_awaited_once_with(request)
8181

8282
@pytest.mark.asyncio
83-
async def test_api_routes_skip_sso(self):
84-
"""API routes should skip SSO and set user to None."""
83+
async def test_api_routes_skip_sso_by_default(self):
84+
"""API routes without @requires_sso should skip SSO and set user to None."""
8585
middleware = self._make_middleware()
8686
request = self._make_request("/api/v1/projects")
8787
expected_response = Response(content="ok")
8888
call_next = AsyncMock(return_value=expected_response)
8989

90-
result = await middleware.dispatch(request, call_next)
90+
with patch.object(middleware, "_route_requires_sso", return_value=False):
91+
result = await middleware.dispatch(request, call_next)
9192

9293
assert result is expected_response
9394
assert request.state.user is None
9495

96+
@pytest.mark.asyncio
97+
async def test_api_route_requires_sso_rejects_unauthenticated(self):
98+
"""API route marked @requires_sso must return 401 when there is no session user."""
99+
middleware = self._make_middleware()
100+
request = self._make_request("/api/metrics-explorer/metrics/minio", session={})
101+
call_next = AsyncMock()
102+
103+
with patch.object(middleware, "_route_requires_sso", return_value=True):
104+
result = await middleware.dispatch(request, call_next)
105+
106+
assert result.status_code == 401
107+
call_next.assert_not_awaited()
108+
109+
@pytest.mark.asyncio
110+
async def test_api_route_requires_sso_allows_authenticated(self):
111+
"""API route marked @requires_sso should pass through for an allowlisted session user."""
112+
middleware = self._make_middleware()
113+
user = {"email": "test@example.com"}
114+
request = self._make_request("/api/metrics-explorer/metrics/minio", session={"user": user})
115+
expected_response = Response(content="ok")
116+
call_next = AsyncMock(return_value=expected_response)
117+
118+
mock_user_service = MagicMock()
119+
mock_user_service.is_email_allowed.return_value = True
120+
121+
with (
122+
patch.object(middleware, "_route_requires_sso", return_value=True),
123+
patch("opi.middleware.authorization.get_user_service", return_value=mock_user_service),
124+
):
125+
result = await middleware.dispatch(request, call_next)
126+
127+
assert result is expected_response
128+
assert request.state.user == user
129+
130+
@pytest.mark.asyncio
131+
async def test_api_route_requires_sso_blocks_non_allowlisted(self):
132+
"""API route marked @requires_sso must return 403 for a non-allowlisted user."""
133+
middleware = self._make_middleware()
134+
user = {"email": "blocked@example.com"}
135+
request = self._make_request("/api/metrics-explorer/metrics/minio", session={"user": user})
136+
call_next = AsyncMock()
137+
138+
mock_user_service = MagicMock()
139+
mock_user_service.is_email_allowed.return_value = False
140+
141+
with (
142+
patch.object(middleware, "_route_requires_sso", return_value=True),
143+
patch("opi.middleware.authorization.get_user_service", return_value=mock_user_service),
144+
):
145+
result = await middleware.dispatch(request, call_next)
146+
147+
assert result.status_code == 403
148+
call_next.assert_not_awaited()
149+
150+
@pytest.mark.asyncio
151+
async def test_metrics_scrape_endpoint_skips_auth(self):
152+
"""The exact /metrics Prometheus scrape endpoint must remain public."""
153+
middleware = self._make_middleware()
154+
request = self._make_request("/metrics")
155+
expected_response = Response(content="ok")
156+
call_next = AsyncMock(return_value=expected_response)
157+
158+
result = await middleware.dispatch(request, call_next)
159+
160+
assert result is expected_response
161+
call_next.assert_awaited_once_with(request)
162+
163+
@pytest.mark.asyncio
164+
async def test_metrics_explorer_is_not_skipped_by_metrics_prefix(self):
165+
"""Regression: /metrics-explorer must NOT be treated as public by the /metrics rule."""
166+
middleware = self._make_middleware()
167+
request = self._make_request("/metrics-explorer", session={})
168+
call_next = AsyncMock()
169+
170+
with patch.object(middleware, "_route_requires_sso", return_value=True):
171+
result = await middleware.dispatch(request, call_next)
172+
173+
assert result.status_code == 302
174+
assert result.headers.get("location") == "/auth/login"
175+
call_next.assert_not_awaited()
176+
95177
@pytest.mark.asyncio
96178
async def test_unauthenticated_user_redirected(self):
97179
"""Should redirect to /auth/login when SSO required and no user."""

0 commit comments

Comments
 (0)