diff --git a/engraphis/dashboard_app.py b/engraphis/dashboard_app.py index b1576640..c5efe7bb 100644 --- a/engraphis/dashboard_app.py +++ b/engraphis/dashboard_app.py @@ -382,9 +382,16 @@ def create_app() -> FastAPI: pass _prev_path = _mcp_mod.mcp.settings.streamable_http_path _prev_security = _mcp_mod.mcp.settings.transport_security + _prev_stateless = getattr(_mcp_mod.mcp.settings, "stateless_http", False) try: _mcp_mod.mcp.settings.streamable_http_path = "/" _mcp_mod.mcp.settings.transport_security = _mcp_transport_security(_mcp_mod.mcp) + # Hosted callers can bind a tenant service and principal around each HTTP + # operation. Stateful Streamable HTTP runs tool callbacks in the persistent + # initialization task, which would retain the first request's ContextVars + # for later requests. Stateless mode keeps every callback in its request + # context and is therefore required for request-scoped service isolation. + _mcp_mod.mcp.settings.stateless_http = True _mcp_asgi = _mcp_mod.mcp.streamable_http_app() finally: # streamable_http_app() captures these settings in its session manager. Restore @@ -392,6 +399,7 @@ def create_app() -> FastAPI: # standalone MCP server in the same process. _mcp_mod.mcp.settings.streamable_http_path = _prev_path _mcp_mod.mcp.settings.transport_security = _prev_security + _mcp_mod.mcp.settings.stateless_http = _prev_stateless _mcp_mgr = _mcp_mod.mcp.session_manager except (Exception, SystemExit) as _exc: # noqa: BLE001 - MCP mount stays optional import logging as _logging @@ -1284,10 +1292,15 @@ def cancel_obsidian_job_alias(job_id: str, request: Request, workspace: str = Fo @app.middleware("http") async def _auth_gate(request: Request, call_next): from engraphis.service import set_current_user + from engraphis.service_context import bound_service # The open runtime has no hosted identity model. Clear any context inherited from # embedding applications and authorize the whole local instance as one principal. - set_current_user(None) + # An embedding host may instead bind a tenant service and validated principal for + # this request; preserve that identity so personal-workspace enforcement remains + # active through dashboard and mounted MCP dispatch. + if bound_service() is None: + set_current_user(None) path = request.url.path if request.method == "OPTIONS": return await call_next(request) diff --git a/tests/test_dashboard_v2.py b/tests/test_dashboard_v2.py index e694d53b..3811570e 100644 --- a/tests/test_dashboard_v2.py +++ b/tests/test_dashboard_v2.py @@ -165,6 +165,57 @@ def test_dashboard_create_workspace_succeeds_when_unbound(monkeypatch, tmp_path) assert response.json()["created"] is True +def test_dashboard_preserves_hosted_principal_for_personal_access( + monkeypatch, tmp_path, +): + import anyio + import httpx + + from engraphis.dashboard_app import create_app + from engraphis.service import current_user, set_current_user + from engraphis.service_context import bind_service + + monkeypatch.setattr(settings, "db_path", str(tmp_path / "hosted-dashboard.db")) + monkeypatch.setattr(settings, "embed_model", "") + monkeypatch.setattr(settings, "embed_dim", 384) + monkeypatch.setattr(settings, "allowed_workspaces", []) + monkeypatch.setattr(settings, "api_token", "") + app = create_app() + hosted = MemoryService.create(":memory:", extractor="none") + owner = {"id": "member_bob", "email": "bob@example.test", "role": "member"} + principal = {"id": "member_alice", "email": "alice@example.test", "role": "member"} + set_current_user(owner) + hosted.create_workspace("bob-private", visibility="personal") + set_current_user(None) + + @app.get("/api/test-hosted-personal", include_in_schema=False) + def hosted_personal_probe(): + from engraphis.routes.v2_api import service + from engraphis.service import ValidationError + + try: + service()._enforce_personal_access("bob-private") + except ValidationError: + allowed = False + else: + allowed = True + return {"allowed": allowed, "user": current_user()} + + async def request_probe(): + transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 50000)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/api/test-hosted-personal") + + try: + with bind_service(hosted, principal=principal): + response = anyio.run(request_probe) + assert response.status_code == 200, response.text + assert response.json() == {"allowed": False, "user": principal} + finally: + hosted.close() + app.state.service.close() + + def test_dashboard_ignores_legacy_workspace_binding_setting(monkeypatch, tmp_path): monkeypatch.setattr(settings, "db_path", str(tmp_path / "legacy-binding.db")) monkeypatch.setattr(settings, "embed_model", "") @@ -876,6 +927,15 @@ def test_dashboard_and_mcp_recall_share_the_v2_service(monkeypatch, tmp_path): assert dashboard["score_semantics"] == mcp["score_semantics"] +def test_dashboard_mcp_mount_is_stateless_for_request_scoped_contexts(monkeypatch, tmp_path): + pytest.importorskip("mcp", reason="MCP extra not installed") + from engraphis import mcp_server + + with _client(monkeypatch, tmp_path) as client: + assert client.app.state.mcp_over_http is True + assert mcp_server.mcp.session_manager.stateless is True + + def test_dashboard_keyword_fallback_reports_truthful_lexical_scores(monkeypatch, tmp_path): with _client(monkeypatch, tmp_path) as client: def mismatched_embedder(*_args, **_kwargs):