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
+85 -9
View File
@@ -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
View File
@@ -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
+178
View File
@@ -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()
+3 -2
View File
@@ -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
View File
@@ -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()