"""Authorization decorators and context for DeerFlow. Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blob/main/libs/sdk-py/langgraph_sdk/auth/__init__.py **Usage:** 1. Use ``@require_auth`` on routes that need authentication 2. Use ``@require_permission("resource", "action", filter_key=...)`` for permission checks 3. The decorator chain processes from bottom to top **Example:** @router.get("/{thread_id}") @require_auth @require_permission("threads", "read", owner_check=True) async def get_thread(thread_id: str, request: Request): # User is authenticated and has threads:read permission ... **Permission Model:** - threads:read - View thread - threads:write - Create/update thread - threads:delete - Delete thread - runs:create - Run agent - runs:read - View run - runs:cancel - Cancel run """ from __future__ import annotations import functools from collections.abc import Callable from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from fastapi import HTTPException, Request if TYPE_CHECKING: from app.gateway.auth.models import User P = ParamSpec("P") T = TypeVar("T") # Permission constants class Permissions: """Permission constants for resource:action format.""" # Threads THREADS_READ = "threads:read" THREADS_WRITE = "threads:write" THREADS_DELETE = "threads:delete" # Runs RUNS_CREATE = "runs:create" RUNS_READ = "runs:read" RUNS_CANCEL = "runs:cancel" class AuthContext: """Authentication context for the current request. Stored in request.state.auth after require_auth decoration. Attributes: user: The authenticated user, or None if anonymous permissions: List of permission strings (e.g., "threads:read") """ __slots__ = ("user", "permissions") def __init__(self, user: User | None = None, permissions: list[str] | None = None): self.user = user self.permissions = permissions or [] @property def is_authenticated(self) -> bool: """Check if user is authenticated.""" return self.user is not None def has_permission(self, resource: str, action: str) -> bool: """Check if context has permission for resource:action. Args: resource: Resource name (e.g., "threads") action: Action name (e.g., "read") Returns: True if user has permission """ permission = f"{resource}:{action}" return permission in self.permissions def require_user(self) -> User: """Get user or raise 401. Raises: HTTPException 401 if not authenticated """ if not self.user: raise HTTPException(status_code=401, detail="Authentication required") return self.user def get_auth_context(request: Request) -> AuthContext | None: """Get AuthContext from request state.""" return getattr(request.state, "auth", None) _ALL_PERMISSIONS: list[str] = [ Permissions.THREADS_READ, Permissions.THREADS_WRITE, Permissions.THREADS_DELETE, Permissions.RUNS_CREATE, Permissions.RUNS_READ, Permissions.RUNS_CANCEL, ] async def _authenticate(request: Request) -> AuthContext: """Authenticate request and return AuthContext. Delegates to deps.get_optional_user_from_request() for the JWT→User pipeline. Returns AuthContext with user=None for anonymous requests. """ from app.gateway.deps import get_optional_user_from_request user = await get_optional_user_from_request(request) if user is None: return AuthContext(user=None, permissions=[]) # In future, permissions could be stored in user record return AuthContext(user=user, permissions=_ALL_PERMISSIONS) def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]: """Decorator that authenticates the request and sets AuthContext. Must be placed ABOVE other decorators (executes after them). Usage: @router.get("/{thread_id}") @require_auth # Bottom decorator (executes first after permission check) @require_permission("threads", "read") async def get_thread(thread_id: str, request: Request): auth: AuthContext = request.state.auth ... Raises: ValueError: If 'request' parameter is missing """ @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: request = kwargs.get("request") if request is None: raise ValueError("require_auth decorator requires 'request' parameter") # Authenticate and set context auth_context = await _authenticate(request) request.state.auth = auth_context return await func(*args, **kwargs) return wrapper def require_permission( resource: str, action: str, owner_check: bool = False, require_existing: bool = False, ) -> Callable[[Callable[P, T]], Callable[P, T]]: """Decorator that checks permission for resource:action. Must be used AFTER @require_auth. Args: resource: Resource name (e.g., "threads", "runs") action: Action name (e.g., "read", "write", "delete") owner_check: If True, validates that the current user owns the resource. Requires 'thread_id' path parameter and performs ownership check. require_existing: Only meaningful with ``owner_check=True``. If True, a missing ``threads_meta`` row counts as a denial (404) instead of "untracked legacy thread, allow". Use on **destructive / mutating** routes (DELETE, PATCH, state-update) so a deleted thread can't be re-targeted by another user via the missing-row code path. Usage: # Read-style: legacy untracked threads are allowed @require_permission("threads", "read", owner_check=True) async def get_thread(thread_id: str, request: Request): ... # Destructive: thread row MUST exist and be owned by caller @require_permission("threads", "delete", owner_check=True, require_existing=True) async def delete_thread(thread_id: str, request: Request): ... Raises: HTTPException 401: If authentication required but user is anonymous HTTPException 403: If user lacks permission HTTPException 404: If owner_check=True but user doesn't own the thread ValueError: If owner_check=True but 'thread_id' parameter is missing """ def decorator(func: Callable[P, T]) -> Callable[P, T]: @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: request = kwargs.get("request") if request is None: raise ValueError("require_permission decorator requires 'request' parameter") auth: AuthContext = getattr(request.state, "auth", None) if auth is None: auth = await _authenticate(request) request.state.auth = auth if not auth.is_authenticated: raise HTTPException(status_code=401, detail="Authentication required") # Check permission if not auth.has_permission(resource, action): raise HTTPException( status_code=403, detail=f"Permission denied: {resource}:{action}", ) # Owner check for thread-specific resources. # # 2.0-rc moved thread metadata into the SQL persistence layer # (``threads_meta`` table). We verify ownership via # ``ThreadMetaStore.check_access``: it returns True for # missing rows (untracked legacy thread) and for rows whose # ``user_id`` is NULL (shared / pre-auth data), so this is # strict-deny rather than strict-allow — only an *existing* # row with a *different* user_id triggers 404. if owner_check: thread_id = kwargs.get("thread_id") if thread_id is None: raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter") from app.gateway.deps import get_thread_store thread_store = get_thread_store(request) allowed = await thread_store.check_access( thread_id, str(auth.user.id), require_existing=require_existing, ) if not allowed: raise HTTPException( status_code=404, detail=f"Thread {thread_id} not found", ) return await func(*args, **kwargs) return wrapper return decorator