fix(dashboard-testing): authenticate browser SSE run stream via query token

This commit is contained in:
2026-09-24 09:39:49 +03:00
parent 233edfd135
commit 6d58bb97d0
6 changed files with 109 additions and 9 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View 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

View File

@@ -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]

View File

@@ -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;