fix(security): isolate bearer authorization paths

This commit is contained in:
RaresKeY
2026-08-28 21:25:23 +00:00
parent 9150a453b4
commit 50c8675a21
24 changed files with 1173 additions and 160 deletions
+42 -1
View File
@@ -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"
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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),