mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-24 07:42:21 +02:00
fix(security): isolate bearer authorization paths
This commit is contained in:
+42
-1
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
|
||||
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, UniqueConstraint, func, inspect, text
|
||||
from sqlalchemy.engine import Engine, make_url
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
@@ -280,6 +280,47 @@ class ChatMessage(Base):
|
||||
Index('ix_messages_session_time', 'session_id', 'timestamp'), # Composite for efficient message retrieval
|
||||
)
|
||||
|
||||
|
||||
class ChatSessionApprovalGrant(Base):
|
||||
"""Server-owned, durable approval provenance for one chat session.
|
||||
|
||||
A resolved tool-approval card is display/history data, not authority. This
|
||||
separate row is inserted only by the interactive approval continuation and
|
||||
is keyed by the real session owner plus session id. It deliberately has no
|
||||
update path; deleting the owning session cascades the grant so an old id
|
||||
cannot carry approval authority into a newly-created conversation.
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_session_approval_grants"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
session_id = Column(
|
||||
String,
|
||||
ForeignKey("sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner = Column(String, nullable=False, index=True)
|
||||
approval_id = Column(String, nullable=False, index=True)
|
||||
provenance_version = Column(Integer, nullable=False, default=1)
|
||||
created_at = Column(DateTime, default=utcnow_naive, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"session_id",
|
||||
"owner",
|
||||
"approval_id",
|
||||
name="uq_chat_session_approval_grant",
|
||||
),
|
||||
Index(
|
||||
"ix_chat_session_approval_grant_lookup",
|
||||
"session_id",
|
||||
"owner",
|
||||
"provenance_version",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Document(TimestampMixin, Base):
|
||||
"""Living document that the AI can create and edit in-place."""
|
||||
__tablename__ = "documents"
|
||||
|
||||
@@ -11,6 +11,7 @@ from starlette.responses import Response
|
||||
from starlette.routing import get_route_path
|
||||
|
||||
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
|
||||
from src.auth_helpers import is_bearer_principal
|
||||
|
||||
|
||||
# Per-process token that lets the in-app tool layer hit admin-gated
|
||||
@@ -59,6 +60,13 @@ def require_admin(request: Request):
|
||||
Allows access when auth is explicitly disabled, or when the request carries
|
||||
the in-process internal-tool token used by loopback agent tools.
|
||||
"""
|
||||
# A bearer principal never inherits admin authority, even when the token
|
||||
# carries a legacy cookbook scope or auth is disabled in a direct-entry
|
||||
# test. Host-control routes use this centralized gate, so rejecting here
|
||||
# covers shell, model-serving, MCP, runtime, and other admin surfaces.
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens cannot use admin host-control surfaces")
|
||||
|
||||
# In-process bypass for tool-layer loopback calls. Two paths:
|
||||
# (a) header-direct (caller set X-Odysseus-Internal-Token), or
|
||||
# (b) the auth middleware already validated the token and stamped
|
||||
|
||||
+12
-37
@@ -10,8 +10,9 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
)
|
||||
from src.message_metadata import sanitize_projected_message_metadata
|
||||
from src.tool_approval_provenance import has_chat_session_approval_grant
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .session_manager import SessionManager
|
||||
@@ -40,29 +41,11 @@ def _history_grants_chat_session_approval(
|
||||
history: List["ChatMessage"],
|
||||
session_id: str,
|
||||
) -> bool:
|
||||
"""Return whether this exact chat has a resolved session-scope grant."""
|
||||
"""Compatibility shim: durable history is never an authority source.
|
||||
|
||||
expected_session = str(session_id or "")
|
||||
if not expected_session:
|
||||
return False
|
||||
for message in reversed(history or []):
|
||||
metadata = getattr(message, "metadata", None)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
tool_events = metadata.get("tool_events")
|
||||
if not isinstance(tool_events, list):
|
||||
continue
|
||||
for event in reversed(tool_events):
|
||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
||||
if not isinstance(ask_user, dict):
|
||||
continue
|
||||
if (
|
||||
ask_user.get("kind") == "tool_approval"
|
||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||
and ask_user.get("approved_by_interactive_session") is True
|
||||
and str(ask_user.get("session_id") or "") == expected_session
|
||||
):
|
||||
return True
|
||||
Keep the old private symbol for downstream imports, but deliberately return
|
||||
false. The live projection checks the separate server-owned grant table.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
@@ -165,21 +148,13 @@ class Session:
|
||||
projected.pop("metadata", None)
|
||||
messages.append(projected)
|
||||
continue
|
||||
if isinstance(metadata, dict) and CHAT_SESSION_APPROVAL_CONTEXT_MARKER in metadata:
|
||||
# The marker is derived below from a verified persisted
|
||||
# approval event. Never pass a raw durable/client marker
|
||||
# through to the model context.
|
||||
metadata = {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if key != CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
}
|
||||
if metadata:
|
||||
projected["metadata"] = metadata
|
||||
else:
|
||||
projected.pop("metadata", None)
|
||||
metadata = sanitize_projected_message_metadata(metadata)
|
||||
if metadata:
|
||||
projected["metadata"] = metadata
|
||||
else:
|
||||
projected.pop("metadata", None)
|
||||
messages.append(projected)
|
||||
if not _history_grants_chat_session_approval(self.history, self.id):
|
||||
if not has_chat_session_approval_grant(self.id, self.owner):
|
||||
return messages
|
||||
|
||||
# Keep the grant close to the latest user request so route-neutral
|
||||
|
||||
+22
-4
@@ -62,6 +62,22 @@ def _parse_msg_content(raw):
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_message_metadata(raw) -> dict:
|
||||
"""Decode only JSON objects from durable message metadata.
|
||||
|
||||
Legacy rows may contain a JSON list (including list-of-pairs) or another
|
||||
scalar. Such values have no trusted message fields and must not reach the
|
||||
``_db_id``/timestamp merge below or any approval projection.
|
||||
"""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return {}
|
||||
return dict(parsed) if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages chat sessions with database persistence.
|
||||
@@ -161,8 +177,7 @@ class SessionManager:
|
||||
# Try relationship first, then direct query
|
||||
if db_session.messages:
|
||||
for db_msg in db_session.messages:
|
||||
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {}
|
||||
if meta is None: meta = {}
|
||||
meta = _parse_message_metadata(db_msg.meta_data)
|
||||
meta['_db_id'] = db_msg.id
|
||||
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
|
||||
history.append(ChatMessage(
|
||||
@@ -176,8 +191,7 @@ class SessionManager:
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
|
||||
for db_msg in db_messages:
|
||||
meta = json.loads(db_msg.meta_data) if db_msg.meta_data else {}
|
||||
if meta is None: meta = {}
|
||||
meta = _parse_message_metadata(db_msg.meta_data)
|
||||
meta['_db_id'] = db_msg.id
|
||||
meta.setdefault('timestamp', _message_timestamp_iso(db_msg.timestamp))
|
||||
history.append(ChatMessage(
|
||||
@@ -254,6 +268,8 @@ class SessionManager:
|
||||
logger.warning("Dropping message for deleted session %s", session_id)
|
||||
return
|
||||
|
||||
if not isinstance(message.metadata, dict):
|
||||
message.metadata = None
|
||||
missing_upload_id = reserve_message_upload_references(
|
||||
getattr(self, "upload_handler", None),
|
||||
getattr(db_session, "owner", None),
|
||||
@@ -366,6 +382,8 @@ class SessionManager:
|
||||
# ownership check/access touch and the replacement transaction.
|
||||
# A failed reservation must leave the existing transcript intact.
|
||||
for message in messages:
|
||||
if not isinstance(message.metadata, dict):
|
||||
message.metadata = None
|
||||
missing_upload_id = reserve_message_upload_references(
|
||||
getattr(self, "upload_handler", None),
|
||||
getattr(db_session, "owner", None),
|
||||
|
||||
Reference in New Issue
Block a user