mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 09:42:21 +01:00
fix(security): close API token agent authorization gaps
This commit is contained in:
@@ -11,11 +11,11 @@ import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import require_interactive_request
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.task_scheduler import compute_next_run
|
||||
|
||||
@@ -78,10 +78,14 @@ def _task_to_checkin_dict(t: ScheduledTask) -> dict:
|
||||
|
||||
|
||||
def setup_assistant_routes(task_scheduler) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/assistant", tags=["assistant"])
|
||||
router = APIRouter(
|
||||
prefix="/api/assistant",
|
||||
tags=["assistant"],
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
|
||||
def _owner(request: Request) -> str:
|
||||
owner = get_current_user(request)
|
||||
owner = require_interactive_request(request)
|
||||
if not owner:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return owner
|
||||
|
||||
@@ -731,6 +731,7 @@ async def build_chat_context(
|
||||
agent_mode=agent_mode,
|
||||
incognito=incognito,
|
||||
use_skills=skills_enabled,
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
)
|
||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||
_preface_kwargs["use_rag"] = use_rag_val
|
||||
|
||||
+84
-29
@@ -9,7 +9,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Form, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -45,6 +45,7 @@ from src.auth_helpers import (
|
||||
enforce_api_token_chat_controls,
|
||||
get_current_user,
|
||||
require_chat_scope,
|
||||
require_interactive_request,
|
||||
)
|
||||
from routes.session_routes import _verify_session_owner
|
||||
from routes.document_helpers import _owner_session_filter
|
||||
@@ -736,7 +737,7 @@ def setup_chat_routes(
|
||||
webhook_manager=None,
|
||||
skills_manager=None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(tags=["chat"])
|
||||
router = APIRouter(tags=["chat"], dependencies=[Depends(require_chat_scope)])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat (non-streaming)
|
||||
@@ -754,6 +755,9 @@ def setup_chat_routes(
|
||||
time_filter = chat_request.time_filter
|
||||
preset_id = chat_request.preset_id
|
||||
|
||||
if getattr(request.state, "api_token", False) is True and use_research:
|
||||
raise HTTPException(403, "API tokens cannot use research or agent execution")
|
||||
|
||||
# Verify the caller owns this session before loading it.
|
||||
# Without this, any authenticated user can post into another user's chat.
|
||||
_verify_session_owner(request, session)
|
||||
@@ -782,15 +786,16 @@ def setup_chat_routes(
|
||||
# non-streaming path can't be used to bypass).
|
||||
_enforce_chat_privileges(request, sess)
|
||||
|
||||
api_token_request = getattr(request.state, "api_token", False) is True
|
||||
tool_policy = build_effective_tool_policy(last_user_message=message)
|
||||
allow_tool_preprocessing = (
|
||||
not getattr(request.state, "api_token", False)
|
||||
not api_token_request
|
||||
and not tool_policy.block_all_tool_calls
|
||||
)
|
||||
|
||||
# Inline memory command
|
||||
memory_response = None
|
||||
if not tool_policy.blocks("manage_memory"):
|
||||
if allow_tool_preprocessing and not tool_policy.blocks("manage_memory"):
|
||||
memory_response = await chat_handler.handle_memory_command(sess, message)
|
||||
if memory_response:
|
||||
return {"response": memory_response}
|
||||
@@ -819,7 +824,7 @@ def setup_chat_routes(
|
||||
tool_policy.blocks("trigger_research")
|
||||
or tool_policy.blocks("manage_research")
|
||||
)
|
||||
if use_research and not research_blocked_by_policy:
|
||||
if use_research and not api_token_request and not research_blocked_by_policy:
|
||||
try:
|
||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||
research_ctx = await research_handler.call_research_service(
|
||||
@@ -919,7 +924,7 @@ def setup_chat_routes(
|
||||
ctx.uprefs, memory_manager, memory_vector, webhook_manager,
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=ctx.user,
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
allow_background_extraction=allow_tool_preprocessing,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -958,6 +963,8 @@ def setup_chat_routes(
|
||||
attachments = form_data.get("attachments")
|
||||
use_web = form_data.get("use_web")
|
||||
use_research = form_data.get("use_research")
|
||||
if use_research is None:
|
||||
use_research = (body or {}).get("use_research")
|
||||
time_filter = form_data.get("time_filter")
|
||||
preset_id = form_data.get("preset_id")
|
||||
selected_endpoint_id = str(
|
||||
@@ -996,10 +1003,35 @@ def setup_chat_routes(
|
||||
approval_id=tool_approval_id,
|
||||
allow_bash=allow_bash,
|
||||
)
|
||||
|
||||
# A bearer token is a chat-only integration credential. Reject every
|
||||
# remaining control-plane input before approval lookup, intent
|
||||
# detection, context shaping, or active-email/workspace resolution can
|
||||
# turn the request into an interactive agent turn.
|
||||
approved_plan = ""
|
||||
if not plan_mode:
|
||||
approved_plan = str(
|
||||
form_data.get("approved_plan")
|
||||
or (body or {}).get("approved_plan")
|
||||
or ""
|
||||
).strip()[:8192]
|
||||
if api_token_request and (
|
||||
str(use_research).lower() == "true"
|
||||
or bool(tool_approval_decision)
|
||||
or bool(approved_plan)
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"API tokens cannot use research, agent, plan, or tool approvals",
|
||||
)
|
||||
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
request, form_data.get("workspace")
|
||||
)
|
||||
if api_token_request:
|
||||
workspace, workspace_rejected = "", ""
|
||||
else:
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
request, form_data.get("workspace")
|
||||
)
|
||||
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
||||
if plan_mode:
|
||||
chat_mode = "agent"
|
||||
@@ -1008,9 +1040,6 @@ def setup_chat_routes(
|
||||
# weak model survives history truncation — the agent can always re-read
|
||||
# the plan. Ignored while still proposing (plan_mode on). Capped so a
|
||||
# huge plan can't blow the prompt.
|
||||
approved_plan = ""
|
||||
if not plan_mode:
|
||||
approved_plan = (form_data.get("approved_plan") or "").strip()[:8192]
|
||||
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
|
||||
# below). Skill extraction should only learn from real agent sessions,
|
||||
# not chats we quietly promoted for a notes/calendar intent.
|
||||
@@ -1045,7 +1074,7 @@ def setup_chat_routes(
|
||||
auto_escalated = False
|
||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||
_workspace_agent_intent = False
|
||||
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
|
||||
@@ -1056,24 +1085,28 @@ def setup_chat_routes(
|
||||
_tool_intent.category,
|
||||
_tool_intent.reason,
|
||||
)
|
||||
elif chat_mode == "chat" and _search_enabled:
|
||||
elif not api_token_request and chat_mode == "chat" and _search_enabled:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info("chat→agent auto-escalation: search enabled")
|
||||
elif chat_mode == "chat" and _explicit_web_intent:
|
||||
elif not api_token_request and chat_mode == "chat" and _explicit_web_intent:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info("chat→agent auto-escalation: explicit web intent")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
active_doc_id = "" if api_token_request else str(form_data.get("active_doc_id") or "").strip()
|
||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||
|
||||
# Active email reader — when the user has an email open in the UI, the
|
||||
# frontend passes its uid/folder/account so "reply", "summarize this",
|
||||
# etc. resolve to the real email instead of the agent inventing a
|
||||
# fake markdown draft.
|
||||
active_email_uid = form_data.get("active_email_uid", "").strip()
|
||||
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
|
||||
active_email_account = form_data.get("active_email_account", "").strip()
|
||||
active_email_uid = "" if api_token_request else str(form_data.get("active_email_uid") or "").strip()
|
||||
active_email_folder = (
|
||||
"INBOX"
|
||||
if api_token_request
|
||||
else str(form_data.get("active_email_folder") or "INBOX").strip() or "INBOX"
|
||||
)
|
||||
active_email_account = "" if api_token_request else str(form_data.get("active_email_account") or "").strip()
|
||||
active_email_ctx: Optional[Dict[str, str]] = None
|
||||
# Always reset between requests so a stale active-email pointer from
|
||||
# a previous turn (different reader closed, different account, etc.)
|
||||
@@ -1246,7 +1279,8 @@ def setup_chat_routes(
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if (
|
||||
chat_mode == "chat"
|
||||
not api_token_request
|
||||
and chat_mode == "chat"
|
||||
and isinstance(message, str)
|
||||
and (not _tool_intent or not _tool_intent.needs_tools)
|
||||
and _is_contextual_web_followup(message, sess)
|
||||
@@ -1260,14 +1294,14 @@ def setup_chat_routes(
|
||||
_tool_intent.category,
|
||||
_tool_intent.reason,
|
||||
)
|
||||
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
||||
if not api_token_request and isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
||||
_explicit_browser_intent = True
|
||||
if chat_mode == "chat":
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = False
|
||||
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
|
||||
if not workspace and isinstance(message, str):
|
||||
if not api_token_request and not workspace and isinstance(message, str):
|
||||
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
|
||||
if _auto_workspace:
|
||||
workspace = _auto_workspace
|
||||
@@ -1309,6 +1343,8 @@ def setup_chat_routes(
|
||||
# mutable form fields, or a stale research_pending session marker,
|
||||
# consume the one-use grant on the unrelated research path.
|
||||
do_research = (
|
||||
not api_token_request
|
||||
and
|
||||
not tool_approval_continuation
|
||||
and str(use_research).lower() == "true"
|
||||
)
|
||||
@@ -1330,7 +1366,11 @@ def setup_chat_routes(
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
||||
|
||||
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
|
||||
image_generation_session = _is_image_generation_session(
|
||||
sess, owner=effective_user(request)
|
||||
)
|
||||
if api_token_request and image_generation_session:
|
||||
raise HTTPException(403, "API tokens cannot use image generation")
|
||||
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
||||
if image_generation_session:
|
||||
no_memory = True
|
||||
@@ -1845,7 +1885,7 @@ def setup_chat_routes(
|
||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||
|
||||
_terminal_saved = False
|
||||
if _is_image_generation_session(sess, owner=_user):
|
||||
if image_generation_session:
|
||||
from src.settings import get_setting
|
||||
if tool_policy.blocks("generate_image"):
|
||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||
@@ -2256,7 +2296,7 @@ def setup_chat_routes(
|
||||
character_name=ctx.preset.character_name,
|
||||
owner=_user,
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
allow_tool_preprocessing
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
@@ -2530,7 +2570,7 @@ def setup_chat_routes(
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
allow_background_extraction=(
|
||||
not tool_policy.block_all_tool_calls
|
||||
allow_tool_preprocessing
|
||||
and not tool_approval_continuation
|
||||
),
|
||||
)
|
||||
@@ -2621,8 +2661,12 @@ def setup_chat_routes(
|
||||
# GET /api/chat/resume — reconnect to a detached run that's still going
|
||||
# (e.g. after reopening a session whose agent kept running in the background)
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.get("/api/chat/resume/{session_id}")
|
||||
@router.get(
|
||||
"/api/chat/resume/{session_id}",
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
||||
require_interactive_request(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
_active_run = agent_runs.get_active_run(session_id)
|
||||
if _active_run is None:
|
||||
@@ -2637,8 +2681,12 @@ def setup_chat_routes(
|
||||
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
|
||||
# no longer stops it (it's detached), so the Stop button must call this.
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat/stop/{session_id}")
|
||||
@router.post(
|
||||
"/api/chat/stop/{session_id}",
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
require_interactive_request(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
||||
stopped = agent_runs.stop(session_id, _expected_run_id)
|
||||
@@ -2647,8 +2695,12 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/chat/stream_status — check if a stream is active for a session
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.get("/api/chat/stream_status/{session_id}")
|
||||
@router.get(
|
||||
"/api/chat/stream_status/{session_id}",
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
async def chat_stream_status(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
require_interactive_request(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
# A detached run can still be going even if _active_streams was popped;
|
||||
# report it as active so the client knows to reconnect via /resume.
|
||||
@@ -2667,6 +2719,7 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/inject_context/{session_id}")
|
||||
async def inject_context(request: Request, session_id: str, context: str = Form(...)) -> Dict[str, str]:
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
sess = session_manager.get_session(session_id)
|
||||
@@ -2686,6 +2739,7 @@ def setup_chat_routes(
|
||||
q: str = Query("", min_length=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
) -> List[Dict[str, Any]]:
|
||||
require_chat_scope(request)
|
||||
if not q or not q.strip():
|
||||
return []
|
||||
|
||||
@@ -2711,6 +2765,7 @@ def setup_chat_routes(
|
||||
Unlike the full chat pipeline, this does NOT run the agent loop or tools.
|
||||
It just asks the LLM to rewrite the given text.
|
||||
"""
|
||||
require_chat_scope(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
|
||||
+11
-13
@@ -16,7 +16,11 @@ from fastapi import APIRouter, BackgroundTasks, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import require_authenticated_request, require_user
|
||||
from src.auth_helpers import (
|
||||
require_api_token_owner,
|
||||
require_authenticated_request,
|
||||
require_user,
|
||||
)
|
||||
from src.tool_implementations import do_manage_notes
|
||||
from src.constants import COOKBOOK_STATE_FILE
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
@@ -84,29 +88,23 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
"""Return the data owner if the caller is allowed for this Codex action."""
|
||||
if getattr(request.state, "api_token", False):
|
||||
if getattr(request.state, "api_token", False) is True:
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if not scopes.intersection(allowed):
|
||||
required = " or ".join(sorted(allowed))
|
||||
raise HTTPException(403, f"API token missing required scope: {required}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
return require_api_token_owner(request)
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||
"""Return owner only when an API token has every required scope."""
|
||||
if getattr(request.state, "api_token", False):
|
||||
if getattr(request.state, "api_token", False) is True:
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
missing = required - scopes
|
||||
if missing:
|
||||
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
return require_api_token_owner(request)
|
||||
return require_user(request)
|
||||
|
||||
|
||||
@@ -119,7 +117,7 @@ def _require_cookbook_scope(request: Request, allowed: set[str]) -> str:
|
||||
commands, and model-serving controls.
|
||||
"""
|
||||
owner = _scope_owner(request, allowed)
|
||||
if not getattr(request.state, "api_token", False):
|
||||
if getattr(request.state, "api_token", False) is not True:
|
||||
require_admin(request)
|
||||
return owner
|
||||
|
||||
@@ -167,7 +165,7 @@ def setup_codex_routes(
|
||||
@router.get("/capabilities")
|
||||
def capabilities(request: Request):
|
||||
token_scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
has_token = bool(getattr(request.state, "api_token", False))
|
||||
has_token = getattr(request.state, "api_token", False) is True
|
||||
def scoped(allowed):
|
||||
return bool(token_scopes.intersection(allowed)) if has_token else True
|
||||
return {
|
||||
|
||||
@@ -145,6 +145,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
if limit is not None:
|
||||
page_limit = max(1, min(int(limit), 100))
|
||||
@@ -247,6 +248,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
|
||||
@router.post("/api/session/{session_id}/truncate")
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -262,6 +264,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/message")
|
||||
async def add_message(request: Request, session_id: str):
|
||||
"""Add a message to a session (for slash command persistence)."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -280,6 +283,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/delete-messages")
|
||||
async def delete_messages(request: Request, session_id: str):
|
||||
"""Delete specific messages by DB ID (or legacy index)."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -343,6 +347,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/edit-message")
|
||||
async def edit_message(request: Request, session_id: str):
|
||||
"""Edit the content of a message by its database ID."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -398,6 +403,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/mark-stopped")
|
||||
async def mark_stopped(request: Request, session_id: str):
|
||||
"""Mark the last assistant message as stopped by user."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
@@ -453,10 +459,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/update-last-meta")
|
||||
async def update_last_meta(request: Request, session_id: str):
|
||||
"""Merge metadata into the last assistant message (e.g. save variants)."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
meta_update = sanitize_client_message_metadata(body.get("metadata", {}))
|
||||
meta_update = sanitize_client_message_metadata(body.get("metadata", {})) or {}
|
||||
session = session_manager.get_session(session_id)
|
||||
|
||||
# Update in-memory
|
||||
@@ -504,6 +511,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/merge-last-assistant")
|
||||
async def merge_last_assistant(request: Request, session_id: str):
|
||||
"""Merge the last two assistant messages into one (for continue)."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -593,6 +601,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/fork")
|
||||
async def fork_session(request: Request, session_id: str):
|
||||
"""Create a new session with messages copied up to keep_count."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -651,6 +660,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
|
||||
@router.get("/api/conversations/topics")
|
||||
async def get_conversation_topics(request: Request) -> Dict[str, Any]:
|
||||
require_chat_scope(request)
|
||||
from src.auth_helpers import require_user
|
||||
user = require_user(request)
|
||||
try:
|
||||
@@ -666,6 +676,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
endpoint estimates the persisted session context so the header can show
|
||||
when the whole chat is approaching compaction.
|
||||
"""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
@@ -710,6 +721,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
@router.post("/api/session/{session_id}/compact")
|
||||
async def compact_session(request: Request, session_id: str):
|
||||
"""Manually trigger context compaction for a session."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
from src.auth_helpers import effective_user
|
||||
owner = effective_user(request)
|
||||
|
||||
@@ -29,7 +29,7 @@ from src.endpoint_resolver import (
|
||||
build_models_url,
|
||||
build_headers,
|
||||
)
|
||||
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
|
||||
from src.auth_helpers import _auth_disabled, owner_filter, require_chat_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1622,13 +1622,7 @@ def setup_model_routes(model_discovery):
|
||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||
# "see everything" by _fetch_models.
|
||||
try:
|
||||
if getattr(request.state, "api_token", False):
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if "chat" not in scopes:
|
||||
raise HTTPException(403, "API token is not scoped for chat")
|
||||
if not getattr(request.state, "api_token_owner", None):
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
owner = effective_user(request) or ""
|
||||
owner = require_chat_scope(request) or ""
|
||||
|
||||
# Reject anonymous in configured deployments — no leaking the model
|
||||
# list to unauthenticated callers.
|
||||
|
||||
@@ -9,12 +9,12 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from core.middleware import INTERNAL_TOOL_USER
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.auth_helpers import _auth_disabled, require_interactive_request
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
|
||||
@@ -207,14 +207,17 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
|
||||
|
||||
def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
router = APIRouter(tags=["research"])
|
||||
router = APIRouter(
|
||||
tags=["research"],
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
|
||||
def _require_user(request: Request) -> str:
|
||||
"""All research endpoints require an authenticated user. Research
|
||||
data isn't owner-scoped in the on-disk JSON yet, so we at least
|
||||
block anonymous access. Multi-tenant deploys should additionally
|
||||
verify the session belongs to this user."""
|
||||
user = get_current_user(request)
|
||||
user = require_interactive_request(request)
|
||||
if not user:
|
||||
if _auth_disabled():
|
||||
return ""
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
import time
|
||||
|
||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||
from services.search.core import _call_provider
|
||||
from services.search.providers import _get_provider_key, _get_search_instance
|
||||
from src.auth_helpers import require_chat_scope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +38,10 @@ async def _request_values(request: Request) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def setup_search_routes(config) -> APIRouter:
|
||||
router = APIRouter(tags=["search"])
|
||||
router = APIRouter(
|
||||
tags=["search"],
|
||||
dependencies=[Depends(require_chat_scope)],
|
||||
)
|
||||
|
||||
@router.get("/api/search/config")
|
||||
async def get_search_settings() -> Dict[str, Any]:
|
||||
@@ -49,6 +53,7 @@ def setup_search_routes(config) -> APIRouter:
|
||||
|
||||
Used by Compare mode to pre-search once and share results across panes.
|
||||
"""
|
||||
require_chat_scope(request)
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
if not query:
|
||||
@@ -87,6 +92,7 @@ def setup_search_routes(config) -> APIRouter:
|
||||
@router.post("/api/search/query")
|
||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||
"""Search using a specific provider. Used by compare search mode."""
|
||||
require_chat_scope(request)
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
provider = str(values.get("provider") or "").strip()
|
||||
|
||||
@@ -225,6 +225,7 @@ def setup_session_routes(
|
||||
|
||||
@router.get("/sessions")
|
||||
def list_sessions(request: Request):
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
|
||||
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
||||
@@ -343,6 +344,7 @@ def setup_session_routes(
|
||||
api_key: str = Form(""),
|
||||
endpoint_id: str = Form(""),
|
||||
):
|
||||
require_chat_scope(request)
|
||||
skip_val = str(skip_validation).lower() == "true"
|
||||
user = effective_user(request)
|
||||
endpoint_api_key = ""
|
||||
@@ -470,6 +472,7 @@ def setup_session_routes(
|
||||
model: str = Form(None), endpoint_url: str = Form(None),
|
||||
endpoint_id: str = Form(None),
|
||||
):
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
session = session_manager.get_session(sid)
|
||||
@@ -544,6 +547,7 @@ def setup_session_routes(
|
||||
@router.post("/session/{sid}/inject_messages")
|
||||
async def inject_messages(request: Request, sid: str):
|
||||
"""Bulk-inject messages into a session's history (for group chat sync)."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
sess = session_manager.get_session(sid)
|
||||
@@ -580,11 +584,13 @@ def setup_session_routes(
|
||||
@router.post("/session/{sid}/delete")
|
||||
def delete_session_beacon(request: Request, sid: str):
|
||||
"""Delete session via POST (for navigator.sendBeacon on page close)."""
|
||||
require_chat_scope(request)
|
||||
return delete_session(request, sid)
|
||||
|
||||
@router.post("/sessions/bulk-delete")
|
||||
async def bulk_delete_sessions(request: Request):
|
||||
"""Delete multiple sessions (for compare cleanup via sendBeacon)."""
|
||||
require_chat_scope(request)
|
||||
from core.database import ChatMessage as _CM
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -614,6 +620,7 @@ def setup_session_routes(
|
||||
@router.delete("/session/{sid}")
|
||||
def delete_session(request: Request, sid: str):
|
||||
"""Permanently delete a session and all its messages."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid, session_manager)
|
||||
try:
|
||||
# Block deletion of starred/favorited sessions
|
||||
@@ -648,6 +655,7 @@ def setup_session_routes(
|
||||
@router.delete("/sessions/all")
|
||||
def delete_all_sessions(request: Request):
|
||||
"""Admin only: permanently delete ALL sessions and their messages."""
|
||||
require_chat_scope(request)
|
||||
from core.middleware import require_admin
|
||||
require_admin(request)
|
||||
|
||||
@@ -701,6 +709,7 @@ def setup_session_routes(
|
||||
@router.post("/session/{sid}/archive")
|
||||
def archive_session(request: Request, sid: str):
|
||||
"""Archive a session, keeping its data but removing it from active sessions."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
# First check if session exists
|
||||
@@ -739,6 +748,7 @@ def setup_session_routes(
|
||||
@router.post("/session/{sid}/unarchive")
|
||||
def unarchive_session(request: Request, sid: str):
|
||||
"""Restore an archived session back to the active session list."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -769,6 +779,7 @@ def setup_session_routes(
|
||||
@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."""
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -816,6 +827,7 @@ def setup_session_routes(
|
||||
|
||||
Supported formats: md (markdown), txt (plain text), json, html
|
||||
"""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
session = session_manager.get_session(sid)
|
||||
@@ -902,6 +914,7 @@ def setup_session_routes(
|
||||
|
||||
@router.post("/sessions/save")
|
||||
def sessions_save_now(request: Request):
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
@@ -915,6 +928,7 @@ def setup_session_routes(
|
||||
model: str = Form("gpt-4o"),
|
||||
rag: str = Form(None)
|
||||
):
|
||||
require_chat_scope(request)
|
||||
if not OPENAI_API_KEY:
|
||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||
sid = str(uuid.uuid4())
|
||||
@@ -936,6 +950,7 @@ def setup_session_routes(
|
||||
@router.post("/session/{session_id}/important")
|
||||
async def mark_session_important(request: Request, session_id: str, important: bool = Form(True)):
|
||||
"""Mark a session as important to protect it from automatic cleanup."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
# Validate session exists
|
||||
@@ -973,6 +988,7 @@ def setup_session_routes(
|
||||
@router.post("/session/{session_id}/compact")
|
||||
async def compact_session(request: Request, session_id: str):
|
||||
"""Summarize older messages into one compacted history entry."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
@@ -1059,6 +1075,7 @@ def setup_session_routes(
|
||||
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
|
||||
users can clean junk without spending tokens.
|
||||
"""
|
||||
require_chat_scope(request)
|
||||
from src.llm_core import llm_call
|
||||
user = effective_user(request)
|
||||
single_user_mode = not user and _auth_disabled()
|
||||
@@ -1339,6 +1356,7 @@ def setup_session_routes(
|
||||
@router.get("/session/{session_id}/context_info")
|
||||
async def get_context_info(request: Request, session_id: str):
|
||||
"""Get the real context length for a session's model from the endpoint."""
|
||||
require_chat_scope(request)
|
||||
_verify_session_owner(request, session_id)
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session:
|
||||
|
||||
@@ -13,11 +13,11 @@ from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import require_interactive_request
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.middleware import require_admin
|
||||
|
||||
@@ -1181,10 +1181,14 @@ async def run_scheduled_skill_audit(skills_manager: SkillsManager,
|
||||
|
||||
|
||||
def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/skills", tags=["skills"])
|
||||
router = APIRouter(
|
||||
prefix="/api/skills",
|
||||
tags=["skills"],
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
|
||||
def _owner(request: Request) -> Optional[str]:
|
||||
return get_current_user(request)
|
||||
return require_interactive_request(request)
|
||||
|
||||
def _verify_owner(skill: dict, user: Optional[str]):
|
||||
if user is None:
|
||||
|
||||
@@ -7,12 +7,12 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||
from core.constants import internal_api_base
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, require_interactive_request
|
||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||
from src.task_action_policy import (
|
||||
ADMIN_ONLY_TASK_ACTIONS,
|
||||
@@ -296,9 +296,17 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
|
||||
def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
router = APIRouter(
|
||||
prefix="/api/tasks",
|
||||
tags=["tasks"],
|
||||
dependencies=[Depends(require_interactive_request)],
|
||||
)
|
||||
|
||||
def _owner(request: Request):
|
||||
# Keep the route-local user lookup injectable for direct handler tests
|
||||
# and legacy callers, but always run the centralized bearer-principal
|
||||
# rejection first.
|
||||
require_interactive_request(request)
|
||||
return get_current_user(request)
|
||||
|
||||
async def _generate_task_name(prompt: str, owner: Optional[str] = None) -> str:
|
||||
|
||||
@@ -265,6 +265,7 @@ def setup_upload_routes(upload_handler):
|
||||
session_id: Optional[str] = Form(None),
|
||||
):
|
||||
"""Upload files with enhanced security and organization."""
|
||||
require_chat_scope(request)
|
||||
if not isinstance(session_id, str):
|
||||
session_id = None
|
||||
if not files:
|
||||
@@ -324,6 +325,7 @@ def setup_upload_routes(upload_handler):
|
||||
@router.post("/cleanup")
|
||||
async def manual_cleanup(request: Request):
|
||||
"""Manually trigger cleanup of old uploads."""
|
||||
require_chat_scope(request)
|
||||
require_admin(request)
|
||||
try:
|
||||
cleaned_count = await asyncio.to_thread(
|
||||
@@ -347,6 +349,7 @@ def setup_upload_routes(upload_handler):
|
||||
@router.get("/stats")
|
||||
async def upload_stats(request: Request):
|
||||
"""Get statistics about uploaded files."""
|
||||
require_chat_scope(request)
|
||||
require_admin(request)
|
||||
try:
|
||||
return upload_handler.get_upload_stats()
|
||||
@@ -359,6 +362,7 @@ def setup_upload_routes(upload_handler):
|
||||
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
||||
JPEG thumbnail for images (used by chat attachment previews) so the
|
||||
client isn't downloading the full-resolution photo just to show it tiny."""
|
||||
require_chat_scope(request)
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
import mimetypes as _mt
|
||||
@@ -457,6 +461,7 @@ def setup_upload_routes(upload_handler):
|
||||
"""Return the vision-model OCR/description for an uploaded image.
|
||||
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
||||
subsequent loads are instant. Pass force=1 to recompute."""
|
||||
require_chat_scope(request)
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
info = _load_upload_info(file_id)
|
||||
@@ -501,6 +506,7 @@ def setup_upload_routes(upload_handler):
|
||||
async def put_vision_text(request: Request, file_id: str):
|
||||
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
||||
the same cache file so the chat send picks it up as the override."""
|
||||
require_chat_scope(request)
|
||||
if not upload_handler.validate_upload_id(file_id):
|
||||
raise HTTPException(400, "Invalid file ID")
|
||||
info = _load_upload_info(file_id)
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.auth_helpers import owner_filter, require_chat_scope
|
||||
from src.url_security import validate_public_http_url
|
||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||
|
||||
@@ -32,14 +32,15 @@ def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||
let a chat-scoped token fall back onto another user's private endpoint and
|
||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||
rows. Fails closed when token_owner is absent; the sync endpoint requires
|
||||
an owner-scoped bearer before this helper is reached.
|
||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||
"""
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if token_owner:
|
||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||
if not token_owner:
|
||||
return None
|
||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||
|
||||
|
||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||
@@ -236,12 +237,11 @@ def setup_webhook_routes(
|
||||
|
||||
@router.post("/v1/chat")
|
||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||
if not getattr(request.state, "api_token", False):
|
||||
if getattr(request.state, "api_token", False) is not True:
|
||||
raise HTTPException(403, "This endpoint requires an API token")
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if "chat" not in scopes:
|
||||
raise HTTPException(403, "API token is not scoped for chat")
|
||||
token_owner = getattr(request.state, "api_token_owner", None)
|
||||
token_owner = require_chat_scope(request)
|
||||
if not token_owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
|
||||
from core.models import ChatMessage
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
Reference in New Issue
Block a user