mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-26 07:42:21 +01:00
security: bind bearer sessions to endpoint provenance
This commit is contained in:
+89
-26
@@ -531,9 +531,20 @@ def resolve_session_auth(
|
||||
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
||||
except Exception:
|
||||
is_chatgpt_subscription = False
|
||||
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
|
||||
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
|
||||
has_auth = _has_auth_keys(sess.headers)
|
||||
if has_auth and not is_chatgpt_subscription:
|
||||
if has_auth and not is_chatgpt_subscription and provenance != "registered":
|
||||
return
|
||||
if provenance == "direct":
|
||||
# A direct API-key session owns its request headers; a same-URL
|
||||
# registered endpoint must never supply another user's credentials by
|
||||
# coincidence.
|
||||
return
|
||||
if provenance == "registered":
|
||||
# Do not carry a previously persisted key through endpoint rotation or
|
||||
# an unavailable endpoint while attempting exact re-resolution below.
|
||||
sess.headers = {}
|
||||
|
||||
try:
|
||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||
@@ -549,6 +560,10 @@ def resolve_session_auth(
|
||||
# with similar endpoint URLs can borrow each other's API key.
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
if provenance == "registered":
|
||||
if not endpoint_id:
|
||||
return
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
for ep in q.all():
|
||||
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
||||
continue
|
||||
@@ -617,6 +632,12 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
if not session_base:
|
||||
return None
|
||||
|
||||
provenance = getattr(sess, "endpoint_provenance", None)
|
||||
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
|
||||
if provenance == "direct":
|
||||
# Direct API-key sessions are intentionally outside the registered
|
||||
# endpoint inventory. Never borrow a same-URL endpoint's model list.
|
||||
return None
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
@@ -624,6 +645,10 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
if provenance == "registered":
|
||||
if not endpoint_id:
|
||||
return None
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
endpoints = q.all()
|
||||
for ep in endpoints:
|
||||
try:
|
||||
@@ -660,41 +685,79 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s
|
||||
sessions, including provider-auth-backed rows, must use the visible
|
||||
server-owned inventory and never trigger a provider lookup here.
|
||||
"""
|
||||
# Lightweight in-memory test doubles from older route tests do not carry
|
||||
# durable provenance fields. They cannot represent a persisted bearer
|
||||
# session; retain their historical seam while every SessionManager-loaded
|
||||
# object (which always has both fields) takes the fail-closed path below.
|
||||
if not hasattr(sess, "endpoint_provenance") and not hasattr(sess, "model_endpoint_id"):
|
||||
return None
|
||||
|
||||
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
|
||||
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
|
||||
if provenance == "direct":
|
||||
if endpoint_id:
|
||||
raise HTTPException(400, "Direct API-key sessions cannot carry a registered endpoint")
|
||||
# No registered ModelEndpoint row is consulted for this documented
|
||||
# compatibility path.
|
||||
return None
|
||||
if provenance != "registered":
|
||||
raise HTTPException(400, "Session endpoint provenance is unavailable")
|
||||
if not owner:
|
||||
raise HTTPException(403, "A bearer session owner is required")
|
||||
if not endpoint_id:
|
||||
raise HTTPException(400, "Registered session endpoint identity is unavailable")
|
||||
|
||||
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||
requested = (getattr(sess, "model", "") or "").strip()
|
||||
if not endpoint_url or not requested:
|
||||
return None
|
||||
try:
|
||||
session_base = normalize_base(endpoint_url)
|
||||
except Exception:
|
||||
session_base = endpoint_url.rstrip("/")
|
||||
if not session_base:
|
||||
return None
|
||||
if not endpoint_url:
|
||||
raise HTTPException(400, "Registered session endpoint is not configured")
|
||||
if not requested:
|
||||
raise HTTPException(400, "Registered session model is not configured")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
for ep in q.all():
|
||||
try:
|
||||
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == endpoint_id,
|
||||
ModelEndpoint.is_enabled == True,
|
||||
)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
if len(endpoints) != 1:
|
||||
# This covers disabled/deleted/owner-mismatched rows as well as
|
||||
# malformed duplicate results. Do not fall back to URL matching.
|
||||
raise HTTPException(400, "Registered model endpoint is no longer available")
|
||||
ep = endpoints[0]
|
||||
if not _session_url_matches_endpoint(endpoint_url, getattr(ep, "base_url", "") or ""):
|
||||
raise HTTPException(400, "Session endpoint provenance is stale")
|
||||
|
||||
sess.model = _validate_bearer_model_selection(ep, requested)
|
||||
return sess.model
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
validated = _validate_bearer_model_selection(ep, requested)
|
||||
|
||||
# A session may outlive an endpoint-key rotation. For bearer calls,
|
||||
# use the current static key for this exact endpoint and never trust a
|
||||
# stale persisted Authorization header. Provider-auth rows remain
|
||||
# request-local and are intentionally empty in cache-only mode.
|
||||
try:
|
||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||
|
||||
base, api_key = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=owner,
|
||||
allow_live_probes=False,
|
||||
)
|
||||
sess.headers = build_headers(api_key, base)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not refresh bearer session endpoint auth: %s", exc)
|
||||
sess.headers = {}
|
||||
|
||||
sess.model = validated
|
||||
return validated
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# No registered endpoint means this is the documented direct API-key
|
||||
# compatibility path, not an endpoint-picker selection.
|
||||
return None
|
||||
|
||||
|
||||
def _session_is_research_spinoff(sess) -> bool:
|
||||
"""True if this session was created via research "Discuss" spin-off.
|
||||
|
||||
+54
-12
@@ -434,11 +434,21 @@ def _clear_orphaned_session_endpoint(
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
|
||||
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
|
||||
if provenance == "direct":
|
||||
return False
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
if provenance == "registered":
|
||||
if not endpoint_id:
|
||||
endpoints = []
|
||||
else:
|
||||
endpoints = q.filter(ModelEndpoint.id == endpoint_id).all()
|
||||
else:
|
||||
endpoints = q.all()
|
||||
for ep in endpoints:
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
|
||||
return False
|
||||
@@ -568,15 +578,24 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
return False
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Prefer the endpoint whose base URL matches the session — we know the
|
||||
# user already pointed this session at that endpoint, so its first
|
||||
# cached model is the most defensible default.
|
||||
provenance = (getattr(sess, "endpoint_provenance", None) or "").strip().lower()
|
||||
endpoint_id = (getattr(sess, "model_endpoint_id", None) or "").strip()
|
||||
has_provenance_fields = hasattr(sess, "endpoint_provenance") or hasattr(sess, "model_endpoint_id")
|
||||
if not allow_live_probes and has_provenance_fields and provenance not in {"registered", "direct"}:
|
||||
return False
|
||||
# Registered sessions use their immutable endpoint identity. URL-only
|
||||
# fallback remains for legacy browser sessions, but is never used to
|
||||
# recover a bearer session with a missing/invalid identity.
|
||||
ep = None
|
||||
if getattr(sess, "endpoint_url", ""):
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
if provenance == "registered":
|
||||
if not endpoint_id:
|
||||
return False
|
||||
q = q.filter(ModelEndpoint.id == endpoint_id)
|
||||
endpoints = q.all()
|
||||
for cand in endpoints:
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
|
||||
@@ -707,6 +726,7 @@ def _reconcile_selected_route_from_request(
|
||||
|
||||
endpoint_url = ""
|
||||
headers = None
|
||||
resolved_endpoint_id = None
|
||||
if selected_endpoint_id or selected_endpoint_url:
|
||||
try:
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -718,16 +738,27 @@ def _reconcile_selected_route_from_request(
|
||||
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
|
||||
if owner:
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
|
||||
ep = None
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
if selected_endpoint_id or _session_url_matches_endpoint(selected_endpoint_url, cand.base_url or ""):
|
||||
ep = cand
|
||||
break
|
||||
if selected_endpoint_id:
|
||||
candidates = [q.first()]
|
||||
else:
|
||||
candidates = [
|
||||
cand for cand in q.all()
|
||||
if cand and _session_url_matches_endpoint(
|
||||
selected_endpoint_url,
|
||||
cand.base_url or "",
|
||||
)
|
||||
]
|
||||
# A URL is not stable identity. Refuse to choose between
|
||||
# duplicate visible endpoints instead of binding the
|
||||
# session to whichever row happens to come first.
|
||||
if len(candidates) != 1:
|
||||
return False
|
||||
ep = candidates[0] if candidates else None
|
||||
if not ep:
|
||||
return False
|
||||
resolved_endpoint_id = str(getattr(ep, "id", "") or "").strip() or None
|
||||
if not resolved_endpoint_id:
|
||||
return False
|
||||
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
|
||||
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
|
||||
finally:
|
||||
@@ -742,12 +773,16 @@ def _reconcile_selected_route_from_request(
|
||||
if (
|
||||
selected_model == (getattr(sess, "model", "") or "")
|
||||
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
|
||||
and resolved_endpoint_id == (getattr(sess, "model_endpoint_id", "") or "")
|
||||
and getattr(sess, "endpoint_provenance", None) == "registered"
|
||||
):
|
||||
return False
|
||||
|
||||
sess.model = selected_model
|
||||
sess.endpoint_url = endpoint_url
|
||||
sess.headers = headers or {}
|
||||
sess.model_endpoint_id = resolved_endpoint_id
|
||||
sess.endpoint_provenance = "registered"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
|
||||
@@ -755,6 +790,8 @@ def _reconcile_selected_route_from_request(
|
||||
db_session.model = selected_model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.headers = sess.headers or {}
|
||||
db_session.model_endpoint_id = resolved_endpoint_id
|
||||
db_session.endpoint_provenance = "registered"
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
@@ -2930,6 +2967,11 @@ def setup_chat_routes(
|
||||
except (KeyError, SessionNotFoundError):
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
if capability.is_bearer:
|
||||
# Rewrite is a direct streaming LLM consumer, so it must enforce
|
||||
# the same server-owned session model/endpoint invariant as chat.
|
||||
_validate_bearer_session_model(sess, owner=effective_user(request))
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are rewriting a previous response. Follow the instruction exactly. "
|
||||
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
from core.database import Comparison, SessionLocal
|
||||
from core.session_manager import SessionManager
|
||||
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
|
||||
from src.session_provenance import persist_session_endpoint_provenance
|
||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -220,15 +221,24 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||
# never inherit another user's key/headers.
|
||||
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||
resolved.append((sid, selected_model, session_endpoint_url, headers))
|
||||
resolved.append(
|
||||
(
|
||||
sid,
|
||||
selected_model,
|
||||
session_endpoint_url,
|
||||
headers,
|
||||
str(ep.id) if ep is not None else None,
|
||||
"registered" if ep is not None else None,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Both endpoints validated — only now create the ephemeral [CMP]
|
||||
# sessions and copy any resolved headers.
|
||||
for sid, model, session_endpoint_url, headers in resolved:
|
||||
for sid, model, session_endpoint_url, headers, endpoint_id, provenance in resolved:
|
||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||
session_manager.create_session(
|
||||
comparison_session = session_manager.create_session(
|
||||
session_id=sid,
|
||||
name=name,
|
||||
endpoint_url=session_endpoint_url,
|
||||
@@ -236,6 +246,14 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
rag=False,
|
||||
owner=user,
|
||||
)
|
||||
if provenance in {"registered", "direct"}:
|
||||
persist_session_endpoint_provenance(
|
||||
session_manager,
|
||||
sid,
|
||||
comparison_session,
|
||||
model_endpoint_id=endpoint_id,
|
||||
endpoint_provenance=provenance,
|
||||
)
|
||||
if headers:
|
||||
s = session_manager.sessions.get(sid)
|
||||
if s:
|
||||
|
||||
@@ -23,12 +23,14 @@ from src.message_metadata import (
|
||||
)
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.session_provenance import persist_session_endpoint_provenance
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
_reject_compact_during_active_run,
|
||||
_verify_session_owner,
|
||||
)
|
||||
from routes.chat_helpers import _validate_bearer_session_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -664,6 +666,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
if not source:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
source_provenance = getattr(source, "endpoint_provenance", None)
|
||||
source_endpoint_id = getattr(source, "model_endpoint_id", None)
|
||||
if (
|
||||
is_bearer_principal(request)
|
||||
and (hasattr(source, "endpoint_provenance") or hasattr(source, "model_endpoint_id"))
|
||||
and source_provenance not in {"registered", "direct"}
|
||||
):
|
||||
raise HTTPException(400, "Session endpoint provenance is unavailable")
|
||||
|
||||
# Create new session
|
||||
new_id = str(uuid.uuid4())
|
||||
fork_name = f"\u2ADD {source.name}"
|
||||
@@ -675,6 +686,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
rag=False,
|
||||
owner=getattr(source, 'owner', None),
|
||||
)
|
||||
if source_provenance in {"registered", "direct"}:
|
||||
persist_session_endpoint_provenance(
|
||||
session_manager,
|
||||
new_id,
|
||||
new_session,
|
||||
model_endpoint_id=source_endpoint_id,
|
||||
endpoint_provenance=source_provenance,
|
||||
)
|
||||
|
||||
# Copy messages up to keep_count
|
||||
msgs_to_copy = source.history[:keep_count]
|
||||
@@ -796,6 +815,9 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
if len(session.history) < 6:
|
||||
return {"status": "ok", "message": "Not enough messages to compact"}
|
||||
|
||||
if capability.is_bearer:
|
||||
_validate_bearer_session_model(session, owner=owner)
|
||||
|
||||
context_kwargs = {}
|
||||
if not capability.allow_live_probes:
|
||||
context_kwargs["allow_live_probes"] = False
|
||||
@@ -927,6 +949,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
"after": pct_after,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Manual compact error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@@ -27,6 +27,7 @@ from src.message_metadata import (
|
||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||
from src.session_actions import is_session_recently_active
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.session_provenance import persist_session_endpoint_provenance
|
||||
|
||||
|
||||
def _sanitize_export_filename(name: str) -> str:
|
||||
@@ -477,6 +478,20 @@ def setup_session_routes(
|
||||
rag=str(rag).lower() == "true" if rag else False,
|
||||
owner=user,
|
||||
)
|
||||
if endpoint_row is not None or request_api_key:
|
||||
try:
|
||||
persist_session_endpoint_provenance(
|
||||
session_manager,
|
||||
sid,
|
||||
session,
|
||||
model_endpoint_id=getattr(endpoint_row, "id", None),
|
||||
endpoint_provenance=(
|
||||
"registered" if endpoint_row is not None else "direct"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to persist session endpoint provenance for %s: %s", sid, exc)
|
||||
raise HTTPException(500, "Failed to persist session endpoint provenance") from exc
|
||||
# Set auth headers for custom API-key endpoints
|
||||
resolved_key = request_api_key
|
||||
resolved_base = endpoint_url
|
||||
@@ -540,6 +555,7 @@ def setup_session_routes(
|
||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||
endpoint_api_key = ""
|
||||
endpoint_base_url = ""
|
||||
endpoint_row = None
|
||||
if endpoint_id:
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -555,13 +571,23 @@ def setup_session_routes(
|
||||
ep = q.first()
|
||||
if not ep:
|
||||
raise HTTPException(400, "Model endpoint no longer exists")
|
||||
endpoint_row = ep
|
||||
endpoint_base_url = ep.base_url or ""
|
||||
endpoint_api_key = ep.api_key or ""
|
||||
endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
|
||||
finally:
|
||||
_db.close()
|
||||
if is_bearer_principal(request) and endpoint_row is not None:
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
# Validate before mutating either the in-memory or durable
|
||||
# session. The same server-owned inventory is enforced again
|
||||
# immediately before each bearer LLM consumer.
|
||||
model = _validate_bearer_model_selection(endpoint_row, model)
|
||||
session.model = model
|
||||
session.endpoint_url = endpoint_url
|
||||
session.model_endpoint_id = getattr(endpoint_row, "id", None)
|
||||
session.endpoint_provenance = "registered" if endpoint_row is not None else None
|
||||
# Update auth headers from the endpoint's stored API key
|
||||
if endpoint_api_key:
|
||||
from src.endpoint_resolver import build_headers
|
||||
@@ -576,6 +602,8 @@ def setup_session_routes(
|
||||
db_session.model = model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.headers = session.headers or {}
|
||||
db_session.model_endpoint_id = getattr(endpoint_row, "id", None)
|
||||
db_session.endpoint_provenance = "registered" if endpoint_row is not None else None
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
finally:
|
||||
@@ -1050,6 +1078,11 @@ def setup_session_routes(
|
||||
if not older:
|
||||
raise HTTPException(400, "Nothing old enough to compact")
|
||||
|
||||
if capability.is_bearer:
|
||||
from routes.chat_helpers import _validate_bearer_session_model
|
||||
|
||||
_validate_bearer_session_model(session, owner=effective_user(request))
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.auth_helpers import (
|
||||
)
|
||||
from src.url_security import validate_public_http_url
|
||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||
from src.session_provenance import persist_session_endpoint_provenance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -365,6 +366,12 @@ def setup_webhook_routes(
|
||||
_sess_owner = getattr(sess, "owner", None)
|
||||
if not _caller_owns_session(_sess_owner, _tok_user):
|
||||
raise HTTPException(404, "Session not found")
|
||||
if is_bearer_principal(request):
|
||||
from routes.chat_helpers import _validate_bearer_session_model
|
||||
|
||||
# Existing-session resume is an LLM boundary too; ownership
|
||||
# alone must not authorize the persisted endpoint/model.
|
||||
_validate_bearer_session_model(sess, owner=token_owner)
|
||||
|
||||
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
||||
if not sess and body.api_key:
|
||||
@@ -397,6 +404,12 @@ def setup_webhook_routes(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
persist_session_endpoint_provenance(
|
||||
session_manager,
|
||||
sid,
|
||||
sess,
|
||||
endpoint_provenance="direct",
|
||||
)
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
@@ -446,12 +459,29 @@ def setup_webhook_routes(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
endpoint_id = getattr(ep, "id", None)
|
||||
if endpoint_id:
|
||||
persist_session_endpoint_provenance(
|
||||
session_manager,
|
||||
sid,
|
||||
sess,
|
||||
model_endpoint_id=endpoint_id,
|
||||
endpoint_provenance="registered",
|
||||
)
|
||||
if api_key:
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
|
||||
# --- Send message and get response ---
|
||||
if is_bearer_principal(request):
|
||||
from routes.chat_helpers import _validate_bearer_session_model
|
||||
|
||||
# The fallback branch has just created the session, so it did not
|
||||
# pass through the existing-session gate above. Recheck the
|
||||
# durable endpoint identity immediately before the LLM boundary
|
||||
# for every bearer path, including malformed endpoint rows.
|
||||
_validate_bearer_session_model(sess, owner=token_owner)
|
||||
sess.add_message(ChatMessage("user", message))
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
||||
|
||||
Reference in New Issue
Block a user