mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 08:22:19 +02:00
fix(security): isolate bearer authorization paths
This commit is contained in:
+85
-9
@@ -1,6 +1,7 @@
|
||||
"""Shared auth helpers used by all route files."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
@@ -11,6 +12,48 @@ from src.owner_identity import (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestCapability:
|
||||
"""Immutable request authority passed through chat execution helpers.
|
||||
|
||||
A bearer token that has the narrow ``chat`` scope is still a pure chat
|
||||
capability. It may complete the synchronous model call, but it cannot
|
||||
create detached execution, emit interactive events, or schedule follow-up
|
||||
work that would run after the request's authorization context is gone.
|
||||
Cookie and AUTH_ENABLED=false requests retain the existing interactive
|
||||
behavior.
|
||||
"""
|
||||
|
||||
principal: str
|
||||
owner: Optional[str]
|
||||
is_bearer: bool
|
||||
allow_deferred_work: bool
|
||||
allow_detached_execution: bool
|
||||
allow_message_events: bool
|
||||
allow_auto_naming: bool
|
||||
|
||||
|
||||
def is_bearer_principal(request: Request) -> bool:
|
||||
"""Return whether the request is attributable to an API-token principal.
|
||||
|
||||
The auth middleware stamps ``state.api_token`` for a verified token. The
|
||||
header/sentinel checks keep direct endpoint calls and auth-disabled
|
||||
alternate entry points fail-closed instead of treating the ``api``
|
||||
sentinel as a normal cookie user.
|
||||
"""
|
||||
state = getattr(request, "state", None)
|
||||
if getattr(state, "api_token", False) is True:
|
||||
return True
|
||||
current_user = getattr(state, "current_user", None)
|
||||
if isinstance(current_user, str) and current_user.strip().casefold() == "api":
|
||||
return True
|
||||
try:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
except Exception:
|
||||
auth_header = ""
|
||||
return isinstance(auth_header, str) and auth_header.strip().casefold().startswith("bearer ody_")
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[str]:
|
||||
"""Get current username from request state (set by auth middleware)."""
|
||||
state = getattr(request, "state", None)
|
||||
@@ -43,9 +86,22 @@ def effective_user(request: Request) -> Optional[str]:
|
||||
|
||||
|
||||
def _is_api_token_request(request: Request) -> bool:
|
||||
"""Return True when middleware authenticated a bearer API token."""
|
||||
state = getattr(request, "state", None)
|
||||
return getattr(state, "api_token", False) is True
|
||||
"""Return True when the request has a bearer API-token principal."""
|
||||
return is_bearer_principal(request)
|
||||
|
||||
|
||||
def request_capability(request: Request) -> RequestCapability:
|
||||
"""Build the one request capability shared by chat downstream helpers."""
|
||||
bearer = is_bearer_principal(request)
|
||||
return RequestCapability(
|
||||
principal="bearer" if bearer else "interactive",
|
||||
owner=effective_user(request),
|
||||
is_bearer=bearer,
|
||||
allow_deferred_work=not bearer,
|
||||
allow_detached_execution=not bearer,
|
||||
allow_message_events=not bearer,
|
||||
allow_auto_naming=not bearer,
|
||||
)
|
||||
|
||||
|
||||
def require_api_token_owner(request: Request) -> str:
|
||||
@@ -65,7 +121,22 @@ def require_api_token_owner(request: Request) -> str:
|
||||
or is_request_sentinel_owner(owner)
|
||||
):
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner.strip()
|
||||
normalized_owner = owner.strip()
|
||||
# The normal auth middleware has already resolved this identity from the
|
||||
# token row. Keep the same invariant for direct endpoint calls and
|
||||
# alternate ASGI entry points when a configured auth manager is available.
|
||||
auth_state = getattr(getattr(request, "app", None), "state", None)
|
||||
auth_manager = getattr(auth_state, "auth_manager", None)
|
||||
users = getattr(auth_manager, "users", None)
|
||||
if (
|
||||
getattr(auth_manager, "is_configured", False)
|
||||
and isinstance(users, dict)
|
||||
and normalized_owner.casefold() not in {
|
||||
str(username).strip().casefold() for username in users
|
||||
}
|
||||
):
|
||||
raise HTTPException(403, "API token owner is not a configured user")
|
||||
return normalized_owner
|
||||
|
||||
|
||||
def require_api_token_scope(request: Request, required_scope: str) -> Optional[str]:
|
||||
@@ -102,13 +173,18 @@ def require_interactive_request(request: Request) -> Optional[str]:
|
||||
approve, or otherwise control interactive agent work.
|
||||
"""
|
||||
current_user = get_current_user(request)
|
||||
if _is_api_token_request(request) or (
|
||||
isinstance(current_user, str) and current_user.strip().casefold() == "api"
|
||||
):
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens cannot use this interactive surface")
|
||||
return current_user
|
||||
|
||||
|
||||
def require_non_bearer_request(request: Request) -> Optional[str]:
|
||||
"""Reject bearer principals while preserving cookie/local route behavior."""
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens cannot use this host-control surface")
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def enforce_api_token_chat_controls(
|
||||
request: Request,
|
||||
*,
|
||||
@@ -137,7 +213,7 @@ def require_authenticated_request(request: Request) -> str:
|
||||
user data. Owner-scoped routes should use ``require_user`` for browser
|
||||
sessions or their own API-token scope/owner gate.
|
||||
"""
|
||||
if _is_api_token_request(request):
|
||||
if is_bearer_principal(request):
|
||||
return require_api_token_owner(request)
|
||||
return require_user(request)
|
||||
|
||||
@@ -178,7 +254,7 @@ def require_user(request: Request) -> str:
|
||||
Use this on routes that touch user data so middleware misconfig can't
|
||||
open them up.
|
||||
"""
|
||||
if _is_api_token_request(request):
|
||||
if is_bearer_principal(request):
|
||||
raise HTTPException(403, "API tokens must use a scope-aware API route")
|
||||
|
||||
u = get_current_user(request)
|
||||
|
||||
+74
-5
@@ -10,6 +10,65 @@ _SERVER_OWNED_MESSAGE_METADATA = frozenset({
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
})
|
||||
|
||||
_APPROVAL_PROVENANCE_FIELDS = frozenset({
|
||||
"approval_id",
|
||||
"approved_by_interactive_session",
|
||||
"resolved",
|
||||
"session_id",
|
||||
})
|
||||
|
||||
|
||||
def _scrub_approval_metadata(value: Any, *, projection: bool, in_approval: bool = False):
|
||||
"""Copy metadata while removing fields that can imply approval authority.
|
||||
|
||||
Client ingress drops every server-owned tool-event container. Context
|
||||
projection keeps harmless server-generated tool-event display data, but
|
||||
strips the approval selectors and resolution/provenance fields from every
|
||||
nested shape. This makes old rows useful for display without allowing a
|
||||
legacy dict, nested dict, or list-shaped payload to become authority.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
kind = value.get("kind")
|
||||
approval_scope = in_approval or kind == "tool_approval"
|
||||
cleaned = {}
|
||||
for key, item in value.items():
|
||||
if key in _SERVER_OWNED_MESSAGE_METADATA and not (
|
||||
projection and key == "tool_events"
|
||||
):
|
||||
continue
|
||||
if key == "tool_approval":
|
||||
continue
|
||||
# These fields have no safe client/display meaning in a message
|
||||
# projection. Strip them even when a legacy writer placed them at
|
||||
# the metadata root instead of under a recognizable approval node.
|
||||
if key in _APPROVAL_PROVENANCE_FIELDS:
|
||||
continue
|
||||
if key == "ask_user":
|
||||
scrubbed = _scrub_approval_metadata(
|
||||
item, projection=projection, in_approval=True
|
||||
)
|
||||
if scrubbed:
|
||||
cleaned[key] = scrubbed
|
||||
continue
|
||||
if key == "tool_events":
|
||||
# Some legacy writers placed approval fields directly on an
|
||||
# event rather than under ask_user. Treat the complete event
|
||||
# container as non-authoritative approval-shaped metadata.
|
||||
cleaned[key] = _scrub_approval_metadata(
|
||||
item, projection=projection, in_approval=True
|
||||
)
|
||||
continue
|
||||
cleaned[key] = _scrub_approval_metadata(
|
||||
item, projection=projection, in_approval=approval_scope
|
||||
)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [
|
||||
_scrub_approval_metadata(item, projection=projection, in_approval=in_approval)
|
||||
for item in value
|
||||
]
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_client_message_metadata(metadata: Any) -> Optional[dict]:
|
||||
"""Normalize client metadata and drop server-owned fields.
|
||||
@@ -24,9 +83,19 @@ def sanitize_client_message_metadata(metadata: Any) -> Optional[dict]:
|
||||
return None
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
sanitized = {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if key not in _SERVER_OWNED_MESSAGE_METADATA
|
||||
}
|
||||
sanitized = _scrub_approval_metadata(metadata, projection=False)
|
||||
return sanitized or None
|
||||
|
||||
|
||||
def sanitize_projected_message_metadata(metadata: Any) -> Optional[dict]:
|
||||
"""Return a model-context copy with approval provenance stripped.
|
||||
|
||||
The projection path may retain non-authoritative tool-event details for
|
||||
continuity, but it never projects the raw chat-session marker or the
|
||||
legacy fields that used to be interpreted as a durable approval grant.
|
||||
A separate server-owned grant store is the only source for that marker.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
cleaned = _scrub_approval_metadata(metadata, projection=True)
|
||||
return cleaned or None
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Durable server-owned provenance for chat-session tool approvals.
|
||||
|
||||
Approval cards and their resolution fields live in the chat transcript for
|
||||
display compatibility. They are intentionally not authority. This module
|
||||
owns the separate database row that can be created only after an interactive
|
||||
server-side ``ExactToolApproval`` was consumed for the matching session and
|
||||
owner. Legacy transcript rows are never migrated into this table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROVENANCE_VERSION = 1
|
||||
|
||||
|
||||
def _owner_key(owner: Any) -> str:
|
||||
value = str(owner or "").strip().casefold()
|
||||
if not value or is_request_sentinel_owner(value):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def _approval_binding_is_valid(
|
||||
approval: Any,
|
||||
*,
|
||||
approval_id: str,
|
||||
session_id: str,
|
||||
owner_key: str,
|
||||
) -> bool:
|
||||
"""Require the exact consumed chat-scope grant before inserting a row."""
|
||||
if approval is None or not getattr(approval, "grants_chat_session", False):
|
||||
return False
|
||||
pending = getattr(approval, "pending", None)
|
||||
if pending is None:
|
||||
return False
|
||||
return (
|
||||
str(getattr(pending, "approval_id", "") or "") == approval_id
|
||||
and str(getattr(pending, "session_id", "") or "") == session_id
|
||||
and _owner_key(getattr(pending, "owner", None)) == owner_key
|
||||
)
|
||||
|
||||
|
||||
def create_chat_session_approval_grant(
|
||||
request,
|
||||
*,
|
||||
approval: Any,
|
||||
approval_id: Any,
|
||||
session_id: Any,
|
||||
owner: Any,
|
||||
) -> bool:
|
||||
"""Persist one interactive chat-session approval grant.
|
||||
|
||||
The caller must supply the exact in-memory approval object returned by the
|
||||
one-use store. Bearer principals are rejected even if they present a
|
||||
client-shaped approval payload. A database failure fails closed by
|
||||
returning ``False``: it never manufactures an in-memory durable grant.
|
||||
"""
|
||||
from src.auth_helpers import effective_user, require_interactive_request
|
||||
|
||||
require_interactive_request(request)
|
||||
from src.tool_approvals import ExactToolApproval
|
||||
|
||||
# This proof is set only by ToolApprovalStore.consume(). In particular,
|
||||
# a client-shaped dict or a hand-constructed ExactToolApproval is not an
|
||||
# interactive approval event and cannot mint durable authority.
|
||||
if not isinstance(approval, ExactToolApproval) or not getattr(
|
||||
approval, "_consumed_from_store", False
|
||||
):
|
||||
return False
|
||||
approval_key = str(approval_id or "")
|
||||
session_key = str(session_id or "")
|
||||
requested_owner_key = _owner_key(owner)
|
||||
if not approval_key or not session_key or (
|
||||
not requested_owner_key and not auth_disabled()
|
||||
):
|
||||
return False
|
||||
if not _approval_binding_is_valid(
|
||||
approval,
|
||||
approval_id=approval_key,
|
||||
session_id=session_key,
|
||||
owner_key=requested_owner_key,
|
||||
):
|
||||
return False
|
||||
|
||||
request_owner_key = _owner_key(effective_user(request))
|
||||
if not auth_disabled() and request_owner_key != requested_owner_key:
|
||||
raise HTTPException(403, "Approval owner does not match the interactive principal")
|
||||
|
||||
# Import lazily so the pure request/auth helpers do not create a database
|
||||
# import cycle during application startup.
|
||||
from core.database import (
|
||||
ChatSessionApprovalGrant,
|
||||
Session as DbSession,
|
||||
SessionLocal,
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session_row = db.query(DbSession).filter(DbSession.id == session_key).first()
|
||||
if session_row is None:
|
||||
return False
|
||||
stored_owner_key = _owner_key(getattr(session_row, "owner", None))
|
||||
if stored_owner_key != requested_owner_key:
|
||||
# AUTH_ENABLED=false is a deliberate single-user compatibility
|
||||
# mode. It may reopen an owner-stamped legacy session, but the
|
||||
# grant remains bound to that stored owner for projection.
|
||||
if not auth_disabled() or requested_owner_key:
|
||||
return False
|
||||
grant_owner_key = stored_owner_key
|
||||
else:
|
||||
grant_owner_key = requested_owner_key
|
||||
|
||||
existing = db.query(ChatSessionApprovalGrant).filter(
|
||||
ChatSessionApprovalGrant.session_id == session_key,
|
||||
ChatSessionApprovalGrant.owner == grant_owner_key,
|
||||
ChatSessionApprovalGrant.approval_id == approval_key,
|
||||
ChatSessionApprovalGrant.provenance_version == _PROVENANCE_VERSION,
|
||||
).first()
|
||||
if existing is not None:
|
||||
return True
|
||||
|
||||
db.add(ChatSessionApprovalGrant(
|
||||
id=uuid.uuid4().hex,
|
||||
session_id=session_key,
|
||||
owner=grant_owner_key,
|
||||
approval_id=approval_key,
|
||||
provenance_version=_PROVENANCE_VERSION,
|
||||
))
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.warning("Could not persist chat-session approval provenance", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def has_chat_session_approval_grant(
|
||||
session_id: Any,
|
||||
owner: Optional[Any],
|
||||
) -> bool:
|
||||
"""Return whether the exact owner/session has a server-owned grant."""
|
||||
session_key = str(session_id or "")
|
||||
owner_key = _owner_key(owner)
|
||||
if (
|
||||
not session_key
|
||||
or (isinstance(owner, str) and is_request_sentinel_owner(owner))
|
||||
or (not owner_key and not auth_disabled())
|
||||
):
|
||||
return False
|
||||
|
||||
from core.database import ChatSessionApprovalGrant, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.query(ChatSessionApprovalGrant).filter(
|
||||
ChatSessionApprovalGrant.session_id == session_key,
|
||||
ChatSessionApprovalGrant.owner == owner_key,
|
||||
ChatSessionApprovalGrant.provenance_version == _PROVENANCE_VERSION,
|
||||
).first() is not None
|
||||
except Exception:
|
||||
# Existing installations are upgraded lazily by Base.metadata.create_all
|
||||
# at startup. Until that has happened, ignoring the absent table is the
|
||||
# safe migration behavior: legacy history can never grant authority.
|
||||
logger.debug("Chat-session approval provenance lookup unavailable", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
@@ -12,8 +12,9 @@ TASK_APPROVAL_DECISION = "approve_task"
|
||||
CHAT_SESSION_APPROVAL_DECISION = "approve"
|
||||
DENY_APPROVAL_DECISION = "deny"
|
||||
|
||||
# Session.get_context_messages() adds this server-owned marker only when the
|
||||
# session history contains a matching, resolved chat-session approval.
|
||||
# Session.get_context_messages() adds this server-owned marker only when a
|
||||
# separate, immutable, owner/session-bound approval grant exists. Transcript
|
||||
# metadata is display-only and never establishes the marker.
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||
|
||||
|
||||
|
||||
+13
-6
@@ -228,6 +228,10 @@ class ExactToolApproval:
|
||||
# sealed action.
|
||||
allow_remaining_actions: bool = True
|
||||
_claimed: bool = field(default=False, init=False, repr=False)
|
||||
# Only ToolApprovalStore.consume() may set this proof. A caller cannot
|
||||
# manufacture chat-session provenance by constructing an ExactToolApproval
|
||||
# around a browser-shaped PendingToolApproval.
|
||||
_consumed_from_store: bool = field(default=False, init=False, repr=False)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
|
||||
|
||||
@property
|
||||
@@ -462,16 +466,19 @@ class ToolApprovalStore:
|
||||
if scope is None:
|
||||
return None
|
||||
if not allow_continuation:
|
||||
return ExactToolApproval(
|
||||
grant = ExactToolApproval(
|
||||
pending,
|
||||
scope=ToolApprovalScope.SINGLE_ACTION,
|
||||
allow_remaining_actions=False,
|
||||
)
|
||||
return ExactToolApproval(
|
||||
pending,
|
||||
scope=scope,
|
||||
allow_remaining_actions=True,
|
||||
)
|
||||
else:
|
||||
grant = ExactToolApproval(
|
||||
pending,
|
||||
scope=scope,
|
||||
allow_remaining_actions=True,
|
||||
)
|
||||
grant._consumed_from_store = True
|
||||
return grant
|
||||
|
||||
def peek(self, approval_id: Any) -> PendingToolApproval | None:
|
||||
now = time.time()
|
||||
|
||||
Reference in New Issue
Block a user