fix(dashboard-testing): authenticate browser SSE run stream via query token
This commit is contained in:
@@ -21,7 +21,12 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user, has_permission
|
||||
from src.dependencies import (
|
||||
get_config_manager,
|
||||
get_current_user,
|
||||
get_current_user_browser_stream,
|
||||
has_permission,
|
||||
)
|
||||
from src.models.scenario_approval import ActionApprovalGate
|
||||
from src.models.scenario_run import ScenarioRun, ScenarioStepRun
|
||||
from src.services.dashboard_testing.execution.object_acl import (
|
||||
@@ -65,6 +70,12 @@ _CONFIG_MANAGER = Depends(get_config_manager)
|
||||
# PROD-classified run authority (catalog pair ("scenario", "RUN_PROD"), display scenario:run_prod).
|
||||
# Guards PROD starts AND the ActionApprovalGate decision endpoint — never the bare RUN scope.
|
||||
_RUN_PROD_PERMISSION = Depends(has_permission("scenario", "RUN_PROD"))
|
||||
# Browser EventSource cannot send Authorization headers, so the SSE stream additionally accepts the
|
||||
# JWT as ?token= (same convention as the /ws channels); validation stays identical.
|
||||
_RUN_EVENTS_USER = Depends(get_current_user_browser_stream)
|
||||
_RUN_EVENTS_PERMISSION = Depends(
|
||||
has_permission("scenario", "RUN", user_resolver=get_current_user_browser_stream)
|
||||
)
|
||||
|
||||
|
||||
class StartRunRequest(BaseModel):
|
||||
@@ -263,8 +274,8 @@ def api_get_run(run_id: str, db=_DB, current_user=_USER, _=_RUN_PERMISSION):
|
||||
def api_run_events(
|
||||
run_id: str,
|
||||
db=_DB,
|
||||
current_user=_USER,
|
||||
_=_RUN_PERMISSION,
|
||||
current_user=_RUN_EVENTS_USER,
|
||||
_=_RUN_EVENTS_PERMISSION,
|
||||
last_event_id: int | None = Header(default=None, alias="Last-Event-ID"),
|
||||
):
|
||||
run = _get_run_or_404(db, run_id, current_user)
|
||||
|
||||
@@ -20,7 +20,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError
|
||||
|
||||
@@ -722,6 +722,34 @@ def get_current_user(
|
||||
# #endregion Dependencies.AppDependencies.GetCurrentUser
|
||||
|
||||
|
||||
# #region Dependencies.AppDependencies.GetCurrentUserBrowserStream [C:3] [TYPE Function]
|
||||
# @ingroup Dependencies
|
||||
# @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser]
|
||||
# @BRIEF JWT resolver for browser streaming endpoints (SSE): Authorization header first, else `?token=`.
|
||||
# @RATIONALE Native EventSource cannot send request headers, so browser SSE clients authenticate
|
||||
# like the existing WebSocket channels: the JWT rides as ?token=. Validation is identical
|
||||
# to get_current_user (decode, blacklist, active-user, session policy) — a transport
|
||||
# fallback, not a weakening.
|
||||
# @REJECTED Global query-token support inside get_current_user — rejected: it would widen
|
||||
# token-in-URL exposure (access logs, browser history) to the entire REST surface.
|
||||
# @PRE None (both channels optional; fail-closed when neither present).
|
||||
# @POST Returns the validated User, or 401 when no credential is present.
|
||||
def get_current_user_browser_stream(
|
||||
header_token: str | None = Depends(oauth2_scheme_optional),
|
||||
query_token: str | None = Query(None, alias="token"),
|
||||
db=Depends(get_auth_db),
|
||||
):
|
||||
token = header_token or query_token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return get_current_user(token=token, x_user_jwt=None, db=db)
|
||||
# #endregion Dependencies.AppDependencies.GetCurrentUserBrowserStream
|
||||
|
||||
|
||||
# #region Dependencies.AppDependencies.ServiceUser [C:1] [TYPE Class]
|
||||
# @ingroup Dependencies
|
||||
# @BRIEF Lightweight user object for service-to-service auth (agent→backend).
|
||||
@@ -860,10 +888,14 @@ def touch_session_activity(db, payload: dict) -> None:
|
||||
# @ingroup Module
|
||||
# @RELATION CALLS -> Core.Repository.AuthRepository
|
||||
# @BRIEF Dependency for checking if the current user has a specific permission.
|
||||
# `user_resolver` overrides the identity source (e.g. the browser-stream JWT resolver for
|
||||
# SSE endpoints, where EventSource cannot send Authorization headers).
|
||||
# @PRE User is authenticated.
|
||||
# @POST Returns True if user has permission.
|
||||
def has_permission(resource: str, action: str):
|
||||
def permission_checker(current_user: User = Depends(get_current_user)):
|
||||
def has_permission(resource: str, action: str, *, user_resolver=None):
|
||||
resolver = user_resolver or get_current_user
|
||||
|
||||
def permission_checker(current_user: User = Depends(resolver)):
|
||||
# Union of all permissions across all roles (normalized comparison)
|
||||
wanted = normalize_permission_pair(resource, action)
|
||||
for role in current_user.roles:
|
||||
|
||||
@@ -48,7 +48,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from src.api.routes.dashboard_testing.scenario_runs import history_router, runs_router
|
||||
from src.core.database import get_db
|
||||
from src.dependencies import get_config_manager, get_current_user
|
||||
from src.dependencies import get_config_manager, get_current_user, get_current_user_browser_stream
|
||||
from src.models.auth import Permission, Role, User
|
||||
from src.models.scenario_run import ScenarioRun
|
||||
from src.services.dashboard_testing.scenario.templates import (
|
||||
@@ -271,6 +271,9 @@ class RunRouteEnv:
|
||||
else:
|
||||
user = SimpleNamespace(id="u1", username="u1", roles=[])
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
# The SSE events route authenticates via the browser-stream resolver (?token= fallback);
|
||||
# override it too so the route contract is exercised without a real JWT.
|
||||
app.dependency_overrides[get_current_user_browser_stream] = lambda: user
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_config_manager] = lambda: self.config_manager
|
||||
return TestClient(app)
|
||||
|
||||
43
backend/tests/test_dependencies_browser_stream.py
Normal file
43
backend/tests/test_dependencies_browser_stream.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# #region Test.Dependencies.BrowserStream [C:3] [TYPE Module] [SEMANTICS test,dependencies,sse,jwt,query-token]
|
||||
# @defgroup Test.Dependencies Browser-stream JWT resolver for SSE endpoints (045 run events).
|
||||
# @BRIEF Falsifiable proof that the SSE resolver takes the Authorization header first, falls back to
|
||||
# the ?token= query param, and fails closed (401) when neither credential is present.
|
||||
# @INVARIANT No credential -> 401; the query fallback delegates to get_current_user unchanged.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import src.dependencies as deps
|
||||
|
||||
|
||||
# #region Test.Dependencies.BrowserStream.NoCredential [C:2] [TYPE Function] [SEMANTICS test,sse,401]
|
||||
# @BRIEF Neither header nor query token -> 401 (fail-closed), never an anonymous user.
|
||||
def test_browser_stream_requires_a_credential():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
deps.get_current_user_browser_stream(header_token=None, query_token=None, db=None)
|
||||
assert exc.value.status_code == 401
|
||||
# #endregion Test.Dependencies.BrowserStream.NoCredential
|
||||
|
||||
|
||||
# #region Test.Dependencies.BrowserStream.QueryFallback [C:3] [TYPE Function] [SEMANTICS test,sse,query-token]
|
||||
# @BRIEF The query token authenticates exactly like the header token via get_current_user.
|
||||
def test_browser_stream_uses_query_token_fallback(monkeypatch):
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def _fake_get_current_user(*, token, x_user_jwt, db):
|
||||
seen["token"] = token
|
||||
seen["x_user_jwt"] = x_user_jwt
|
||||
return "resolved-user"
|
||||
|
||||
monkeypatch.setattr(deps, "get_current_user", _fake_get_current_user)
|
||||
|
||||
# EventSource cannot set the Authorization header, so only the query token is present.
|
||||
assert deps.get_current_user_browser_stream(header_token=None, query_token="query-jwt", db="db") == "resolved-user"
|
||||
assert seen == {"token": "query-jwt", "x_user_jwt": None}
|
||||
|
||||
# A header token wins when both are supplied.
|
||||
assert deps.get_current_user_browser_stream(header_token="header-jwt", query_token="query-jwt", db="db") == "resolved-user"
|
||||
assert seen["token"] == "header-jwt"
|
||||
# #endregion Test.Dependencies.BrowserStream.QueryFallback
|
||||
# #endregion Test.Dependencies.BrowserStream
|
||||
@@ -238,6 +238,16 @@ export const getTranslateRunWsUrl = (runId: string): string => {
|
||||
return _appendWsCredentials(`${protocol}//${host}/ws/translate/run/${runId}`);
|
||||
};
|
||||
// #endregion Api.ApiModule.GetTranslateRunWsUrl
|
||||
|
||||
/**
|
||||
* Build an authenticated URL for the scenario-run SSE event stream.
|
||||
* Native EventSource cannot send Authorization headers, so the JWT rides as ?token=
|
||||
* (backend resolves it for this endpoint; same convention as the WS builders above).
|
||||
*/
|
||||
// #region Api.ApiModule.GetScenarioRunEventsUrl [C:1] [TYPE Function] [SEMANTICS api, sse, scenario, run, events, url]
|
||||
export const getScenarioRunEventsUrl = (runId: string): string =>
|
||||
_appendWsCredentials(`/api/scenario-runs/${encodeURIComponent(runId)}/events`);
|
||||
// #endregion Api.ApiModule.GetScenarioRunEventsUrl
|
||||
// #endregion Api.ApiModule.WsUrlHelpers
|
||||
|
||||
// #region Api.ApiModule.GetAuthHeaders [C:2] [TYPE Function] [SEMANTICS auth, headers, token, localStorage]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// @ACTION launch, bindEvents, dispose, decideApproval, cancel, loadRun, loadResult, loadHistory, loadComparison, clear
|
||||
// @INVARIANT SSE events update typed step/status fields only; prose is never parsed.
|
||||
// @INVARIANT Launch posts the typed 044 start contract; release/baseline/toggles never hide in params.
|
||||
import { api } from "$lib/api";
|
||||
import { api, getScenarioRunEventsUrl } from "$lib/api";
|
||||
import type { CheckpointDisposition, RunComparison, RunConfiguration, ScenarioExecutionResult, ScenarioRun, ScenarioStepRun } from "$lib/types/scenario-run";
|
||||
|
||||
export class RunMonitorModel {
|
||||
@@ -50,7 +50,8 @@ export class RunMonitorModel {
|
||||
bindEvents(runId: string, sourceFactory: (url: string) => EventSource = (_url) => new EventSource(_url)): void {
|
||||
this.eventSource?.close();
|
||||
try {
|
||||
this.eventSource = sourceFactory(`/api/scenario-runs/${encodeURIComponent(runId)}/events`);
|
||||
// EventSource cannot set Authorization headers; the builder appends the JWT as ?token=.
|
||||
this.eventSource = sourceFactory(getScenarioRunEventsUrl(runId));
|
||||
} catch {
|
||||
this.eventSource = null;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user