Attribute API-token sessions to the token owner (effective_user) (#871)

Split 2/4 of the companion bridge (#863 was 1/4). A paired bearer-token caller
runs as the sandboxed 'api' pseudo-user, so its sessions were stranded in a
separate 'api'-owned silo, invisible to the owner's desktop UI.

Add effective_user(): for a bearer token it resolves to the token's real owner
(request.state.api_token_owner); for cookie sessions it is identical to
get_current_user, so the swap is a no-op for browser users. Route session
ownership/attribution in routes/session_routes.py through it.

Tests (tests/test_session_owner_attribution.py):
- cookie/browser users are unchanged
- a bearer token attributes to its owner; with no owner it does NOT escalate
- _verify_session_owner: a bearer token for owner A cannot verify owner B's
  session (404); owner verifies their own; missing -> 404; unauth -> 403
This commit is contained in:
Mahdi Salmanzade
2026-06-02 06:39:01 +04:00
committed by GitHub
parent bc00a9fc7f
commit 54ac4a74fb
3 changed files with 145 additions and 8 deletions

View File

@@ -11,12 +11,12 @@ from core.session_manager import SessionManager
from core.models import ChatMessage
from src.request_models import SessionResponse
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, effective_user
def _verify_session_owner(request: Request, session_id: str):
"""Verify the current user owns the session. Raises 404 if not."""
user = get_current_user(request)
user = effective_user(request)
if not user:
raise HTTPException(403, "Authentication required")
db = SessionLocal()
@@ -63,7 +63,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.get("/sessions")
def list_sessions(request: Request):
user = get_current_user(request)
user = effective_user(request)
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
# from the DB and session_manager so they vanish on the next page refresh.
# BUT: skip sessions that were created within the last 10 minutes.
@@ -217,7 +217,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
model_to_use = found
sid = str(uuid.uuid4())
user = get_current_user(request)
user = effective_user(request)
session = session_manager.create_session(
session_id=sid,
name=name or "",
@@ -499,7 +499,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.get("/sessions/archived")
def list_archived_sessions(request: Request, search: str = "", offset: int = 0, limit: int = 20, sort: str = "recent", model: str = ""):
"""List archived sessions for the archive browser."""
user = get_current_user(request)
user = effective_user(request)
db = SessionLocal()
try:
q = db.query(DbSession).filter(DbSession.archived == True)
@@ -635,7 +635,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
@router.post("/sessions/save")
def sessions_save_now(request: Request):
user = get_current_user(request)
user = effective_user(request)
if not user:
raise HTTPException(401, "Not authenticated")
session_manager.save_sessions()
@@ -651,7 +651,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
if not OPENAI_API_KEY:
raise HTTPException(400, "Server missing OPENAI_API_KEY")
sid = str(uuid.uuid4())
user = get_current_user(request)
user = effective_user(request)
session = session_manager.create_session(
session_id=sid,
name="",
@@ -791,7 +791,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
users can clean junk without spending tokens.
"""
from src.llm_core import llm_call
user = get_current_user(request)
user = effective_user(request)
user_sessions = session_manager.get_sessions_for_user(user)
# Delete empty and throwaway sessions before sorting

View File

@@ -10,6 +10,30 @@ def get_current_user(request: Request) -> Optional[str]:
return getattr(request.state, 'current_user', None)
def effective_user(request: Request):
"""The real human behind the request, for ownership/attribution.
Cookie sessions resolve to the logged-in username. Bearer ``ody_`` callers
come through as the sandboxed pseudo-user "api" so they can't wander into
cookie/user routes by default, but their token was minted by, and belongs
to, a real owner stamped on ``request.state.api_token_owner``. Routes that
should attribute a token's actions to that owner (sessions, chat history)
call this instead of :func:`get_current_user`, so a paired client sees and
creates the SAME data as the owner's desktop UI rather than a separate
"api"-owned silo.
For cookie sessions this is identical to :func:`get_current_user`, so
swapping a route over is a no-op for browser users. A bearer token with no
owner falls back to :func:`get_current_user` (the "api" pseudo-user), so it
never escalates.
"""
if getattr(request.state, "api_token", False):
owner = getattr(request.state, "api_token_owner", None)
if owner:
return owner
return get_current_user(request)
def _auth_disabled() -> bool:
"""True when the operator has explicitly turned off auth via .env.
Mirrors the AUTH_ENABLED parse in app.py / core/middleware.py so the

View File

@@ -0,0 +1,113 @@
"""Tests for token-owner session attribution (effective_user + session routes).
Proves the two properties the review asked for:
- cookie/browser users are completely unchanged (no-op swap)
- a bearer token for owner A can never read/verify owner B's session, and a
bearer token with no owner does not escalate.
Follows the direct-helper + mocked-DB style of tests/test_null_owner_gates.py.
"""
import os
import sys
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# routes.session_routes imports several heavy modules at import time that blow up
# under conftest's sqlalchemy/* MagicMock stubs (declarative classes). Stub them
# so we can import the module and exercise _verify_session_owner with a mock DB.
_STUBS = {
"core.database": {"Session": MagicMock(), "SessionLocal": MagicMock(),
"Document": MagicMock(), "GalleryImage": MagicMock()},
"core.session_manager": {"SessionManager": MagicMock()},
"core.models": {"ChatMessage": MagicMock()},
"src.request_models": {"SessionResponse": MagicMock()},
}
for _name, _attrs in _STUBS.items():
if _name not in sys.modules:
_m = types.ModuleType(_name)
for _k, _v in _attrs.items():
setattr(_m, _k, _v)
sys.modules[_name] = _m
from fastapi import HTTPException # noqa: E402
from src.auth_helpers import effective_user # noqa: E402
import routes.session_routes as SR # noqa: E402
def _req(**state):
return SimpleNamespace(state=SimpleNamespace(**state))
# --- effective_user: who a request is attributed to ------------------------
def test_cookie_user_is_unchanged():
# The whole point: browser/cookie callers behave exactly as before.
assert effective_user(_req(api_token=False, current_user="alice")) == "alice"
def test_bearer_token_attributes_to_its_owner():
# A paired phone runs as the "api" pseudo-user but must act as the token owner.
assert effective_user(_req(api_token=True, api_token_owner="alice", current_user="api")) == "alice"
def test_bearer_token_without_owner_does_not_escalate():
# No owner on the token -> falls back to current_user ("api"), never another user.
assert effective_user(_req(api_token=True, api_token_owner=None, current_user="api")) == "api"
# --- _verify_session_owner: bearer tokens cannot cross owners ---------------
def _session_local_returning(owner_value):
"""Mock SessionLocal whose query(...).filter(...).first() yields a row with
the given owner (or None for 'no such session')."""
db = MagicMock()
row = None if owner_value is _MISSING else SimpleNamespace(owner=owner_value)
db.query.return_value.filter.return_value.first.return_value = row
return MagicMock(return_value=db)
_MISSING = object()
def test_bearer_owner_A_cannot_verify_owner_B_session(monkeypatch):
monkeypatch.setattr(SR, "SessionLocal", _session_local_returning("bob"))
req = _req(api_token=True, api_token_owner="alice", current_user="api")
with pytest.raises(HTTPException) as exc:
SR._verify_session_owner(req, "sid-owned-by-bob")
assert exc.value.status_code == 404
def test_owner_can_verify_their_own_session(monkeypatch):
monkeypatch.setattr(SR, "SessionLocal", _session_local_returning("alice"))
req = _req(api_token=True, api_token_owner="alice", current_user="api")
# Should not raise.
SR._verify_session_owner(req, "sid-owned-by-alice")
def test_cookie_user_owns_their_session(monkeypatch):
# Cookie path unchanged: alice (cookie) verifies alice's session.
monkeypatch.setattr(SR, "SessionLocal", _session_local_returning("alice"))
req = _req(api_token=False, current_user="alice")
SR._verify_session_owner(req, "sid")
def test_missing_session_is_404(monkeypatch):
monkeypatch.setattr(SR, "SessionLocal", _session_local_returning(_MISSING))
req = _req(api_token=False, current_user="alice")
with pytest.raises(HTTPException) as exc:
SR._verify_session_owner(req, "nope")
assert exc.value.status_code == 404
def test_unauthenticated_caller_rejected(monkeypatch):
req = _req(api_token=False, current_user=None)
with pytest.raises(HTTPException) as exc:
SR._verify_session_owner(req, "sid")
assert exc.value.status_code == 403