88"""
99
1010import asyncio
11+ import hmac
12+ import ipaddress
1113import logging
1214import os
1315import time
1416from concurrent .futures import ThreadPoolExecutor
1517from contextlib import asynccontextmanager
1618
1719from dotenv import load_dotenv
18- from fastapi import FastAPI , Request
20+ from fastapi import Depends , FastAPI , HTTPException , Request
1921from fastapi .middleware .cors import CORSMiddleware
2022from fastapi .responses import FileResponse , JSONResponse
2123from starlette .requests import ClientDisconnect
@@ -146,27 +148,63 @@ async def lifespan(app: FastAPI):
146148
147149app = FastAPI (title = "NVIDIA OO Agents Viewer" , version = "2.0.0" , lifespan = lifespan )
148150
151+
152+ def _require_viewer_authorization (request : Request ) -> None :
153+ """Authorize a protected viewer API route when an auth token is configured."""
154+ expected = os .environ .get ("NOOA_VIEWER_AUTH_TOKEN" )
155+ if not expected :
156+ client_host = request .client .host if request .client else ""
157+ try :
158+ is_loopback = ipaddress .ip_address (client_host ).is_loopback
159+ except ValueError :
160+ # Starlette's in-process test transport uses this non-network host.
161+ is_loopback = client_host == "testclient"
162+ if is_loopback :
163+ return
164+ raise HTTPException (
165+ status_code = 403 ,
166+ detail = "set NOOA_VIEWER_AUTH_TOKEN before exposing the viewer" ,
167+ )
168+
169+ authorization = request .headers .get ("Authorization" , "" )
170+ if not hmac .compare_digest (authorization , f"Bearer { expected } " ):
171+ raise HTTPException (
172+ status_code = 401 ,
173+ detail = "viewer authorization required" ,
174+ headers = {"WWW-Authenticate" : "Bearer" },
175+ )
176+
177+
178+ _cors_origins = [
179+ origin .strip ()
180+ for origin in os .environ .get (
181+ "NOOA_VIEWER_CORS_ORIGINS" , "http://localhost:5001,http://127.0.0.1:5001"
182+ ).split ("," )
183+ if origin .strip ()
184+ ]
185+ _protected = [Depends (_require_viewer_authorization )]
186+
149187app .add_middleware (
150188 CORSMiddleware ,
151- allow_origins = [ "*" ] ,
189+ allow_origins = _cors_origins ,
152190 allow_credentials = True ,
153- allow_methods = ["* " ],
154- allow_headers = ["* " ],
191+ allow_methods = ["GET" , "POST" , "PATCH" , "DELETE " ],
192+ allow_headers = ["Authorization" , "Content-Type" , "X-Session-Id " ],
155193)
156194
157- app .include_router (trace_router )
158- app .include_router (eval_router )
159- app .include_router (annotation_router )
160- app .include_router (explorer_router )
161- app .include_router (memory_router )
195+ app .include_router (trace_router , dependencies = _protected )
196+ app .include_router (eval_router , dependencies = _protected )
197+ app .include_router (annotation_router , dependencies = _protected )
198+ app .include_router (explorer_router , dependencies = _protected )
199+ app .include_router (memory_router , dependencies = _protected )
162200
163201
164202# ============================================================================
165203# OTLP ingest endpoint
166204# ============================================================================
167205
168206
169- @app .post ("/v1/traces" )
207+ @app .post ("/v1/traces" , dependencies = _protected )
170208async def otlp_ingest (request : Request ):
171209 """Accept OTLP JSON ExportTraceServiceRequest and queue for async SQLite write.
172210
@@ -201,7 +239,7 @@ async def otlp_ingest(request: Request):
201239# ============================================================================
202240
203241
204- @app .post ("/v1/sync" )
242+ @app .post ("/v1/sync" , dependencies = _protected )
205243async def sync_ingest ():
206244 """Block until the ingest queue is fully drained and all spans are in SQLite.
207245
@@ -228,7 +266,7 @@ async def sync_ingest():
228266# ============================================================================
229267
230268
231- @app .post ("/v1/journal/messages" )
269+ @app .post ("/v1/journal/messages" , dependencies = _protected )
232270async def journal_messages_ingest (request : Request ):
233271 """Accept a batch of content-addressed message records.
234272
@@ -269,7 +307,7 @@ async def journal_messages_ingest(request: Request):
269307 return JSONResponse (content = result )
270308
271309
272- @app .post ("/v1/journal/calls" )
310+ @app .post ("/v1/journal/calls" , dependencies = _protected )
273311async def journal_call_ingest (request : Request ):
274312 """Accept a single LLM call record with input/output hash lists.
275313
@@ -309,7 +347,7 @@ async def journal_call_ingest(request: Request):
309347 return JSONResponse (content = result )
310348
311349
312- @app .post ("/v1/journal/blocks" )
350+ @app .post ("/v1/journal/blocks" , dependencies = _protected )
313351async def journal_blocks_ingest (request : Request ):
314352 """Accept a batch of content-addressed message blocks for a session.
315353
@@ -363,7 +401,7 @@ async def journal_blocks_ingest(request: Request):
363401 return JSONResponse (content = result )
364402
365403
366- @app .get ("/api/traces/{session_id:path}/calls" )
404+ @app .get ("/api/traces/{session_id:path}/calls" , dependencies = _protected )
367405def get_session_calls (session_id : str ):
368406 """Return all LLM calls for a session with fully reconstructed messages."""
369407 if not otlp_store .session_exists (session_id ):
@@ -376,7 +414,7 @@ def get_session_calls(session_id: str):
376414# ============================================================================
377415
378416
379- @app .post ("/api/refresh" )
417+ @app .post ("/api/refresh" , dependencies = _protected )
380418def refresh_all ():
381419 """Return current store stats."""
382420 stats = otlp_store .get_stats ()
0 commit comments