mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 19:06:01 +00:00
aa015462a7
* Add user-owned IM channel connections * Fix dev startup and channel connect popup * Use async channel connect flow * Harden dev service daemon startup * Support local IM channel connections * Align IM connections with local channels * Fix safe user id digest algorithm * Address Copilot IM channel feedback * Address IM channel review comments * Support all integrated IM channel connections * Format additional channel connection tests * Keep unavailable channel connect buttons clickable * Fix IM channel provider icons * Add runtime setup for enabled IM channels * Guard global shortcut key handling * Keep configured IM channels editable * Avoid password autofill for channel secrets * Make channel threads visible to connection owners * Persist IM runtime config locally * Allow disconnecting runtime IM channels * Route no-auth channel sessions to local user * Use default user for auth-disabled local mode * Show IM channel source on threads * Prefill IM channel runtime config * Reflect IM channel runtime health * Ignore Feishu message read events * Ignore Feishu non-content message events * Let setup wizard enable IM channels * Fix frontend formatting after merge * Stabilize backend tests without local config * Isolate channel runtime config tests * Address channel connection review comments * Use sha256 user buckets with legacy migration * Ensure runtime IM channels are ready after restart * Persist disconnected IM channel state * Address channel connection review comments * Address channel connection review findings Frontend connect flow: - Open the runtime-config dialog only when a provider still needs credentials; configured providers go straight to the connect flow, so the binding-code/deep-link path is reachable from the UI again. - After saving credentials, continue into the connect flow when a user binding is still required (multi-user mode) instead of stopping at a "Connected" toast. - Extract shared provider-state helpers to core/channels/provider-state and add unit + e2e coverage for the direct-connect and configure-then-connect paths. Provider status semantics: - Report connection_status from the user's newest connection row; with no binding it is not_connected, except in auth-disabled local mode where a configured running channel is effectively connected. Concurrency and event-loop correctness: - Offload ChannelRuntimeConfigStore construction and writes, channel service construction, and Slack connection replies to threads; add a tests/blocking_io/ anchor for the runtime-config handlers. - Consume binding codes with a conditional UPDATE so a code can only be used once under concurrent workers; retry upsert_connection as an update when a concurrent insert wins the unique constraint. - Serialize ensure_channel_ready per channel so concurrent provider polls cannot double-start a channel worker. Config and migration hardening: - Stop mutating the get_app_config()-cached Telegram provider config; the runtime store now owns the UI-entered bot username. - Register channel_connections in STARTUP_ONLY_FIELDS with the standardized startup-only Field description. - Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name so another user's same-prefix bucket can never be migrated. - Remove the unused Telegram process_webhook_update path and document src/core/channels in the frontend docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address PR review comments on authz scoping and channel runtime Security (review feedback from ShenAC-SAC): - Scope internal-token callers to the connection owner carried in X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright, in both require_permission(owner_check=True) and the stateless run endpoints. Internal callers keep access to their own and shared/legacy threads, and may claim a default-owned channel thread for its real owner, but a leaked internal token no longer grants cross-user thread access. - Require admin privileges for POST/DELETE /api/channels/{provider}/ runtime-config: runtime credentials and channel workers are instance-wide shared state (same model as the MCP config API). Read-only provider listing stays available to all users. Performance (review feedback from willem-bd): - Skip the redundant thread channel-metadata PATCH after the first successful backfill per thread. - Reuse the per-connection Slack WebClient until its token changes instead of constructing one per outbound message. - Reconcile channel readiness for all providers concurrently in GET /api/channels/providers. Also resolve the code-quality unused-import flag in the blocking-io anchor by pre-importing the channel service via importlib. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix prettier formatting in provider-state test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reconcile UI runtime channel config with config reload on restart Main now reloads a channel's config.yaml entry on restart_channel() (#3514, issue #3497). Adapt the user-owned connection flow to coexist: - configure_channel() restarts with reload_config=False — the caller just supplied the authoritative config (browser-entered credentials that are never written to config.yaml), so a file reload must not clobber it with the stale on-disk entry. - _load_channel_config() re-applies the UI runtime-store overlay used at startup, so an operator-triggered restart keeps browser-entered credentials for channels without a config.yaml entry and does not resurrect a channel disconnected from the UI. - Offload the reload's disk IO (config.yaml + runtime store) with asyncio.to_thread, matching the blocking-IO policy on this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""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
|
|
import inspect
|
|
from collections.abc import Callable
|
|
from types import SimpleNamespace
|
|
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,
|
|
]
|
|
|
|
|
|
def _make_test_request_stub() -> Any:
|
|
"""Create a minimal request-like object for direct unit calls.
|
|
|
|
Used when decorated route handlers are invoked without FastAPI's
|
|
request injection. Includes fields accessed by auth helpers.
|
|
"""
|
|
return SimpleNamespace(state=SimpleNamespace(), cookies={}, _deerflow_test_bypass_auth=True)
|
|
|
|
|
|
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 enforces authentication.
|
|
|
|
Independently raises HTTP 401 for unauthenticated requests, regardless of
|
|
whether ``AuthMiddleware`` is present in the ASGI stack. Sets the resolved
|
|
``AuthContext`` on ``request.state.auth`` for downstream handlers.
|
|
|
|
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:
|
|
HTTPException: 401 if the request is unauthenticated.
|
|
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:
|
|
# Unit tests may call decorated handlers directly without a
|
|
# FastAPI Request object. Inject a minimal request stub when
|
|
# the wrapped function declares `request`.
|
|
if "request" in inspect.signature(func).parameters:
|
|
kwargs["request"] = _make_test_request_stub()
|
|
else:
|
|
raise ValueError("require_auth decorator requires 'request' parameter")
|
|
request = kwargs["request"]
|
|
|
|
if getattr(request, "_deerflow_test_bypass_auth", False):
|
|
return await func(*args, **kwargs)
|
|
|
|
# Authenticate and set context
|
|
auth_context = await _authenticate(request)
|
|
request.state.auth = auth_context
|
|
|
|
if not auth_context.is_authenticated:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
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:
|
|
# Unit tests may call decorated route handlers directly without
|
|
# constructing a FastAPI Request object. Inject a minimal stub
|
|
# when the wrapped function declares `request`.
|
|
if "request" in inspect.signature(func).parameters:
|
|
kwargs["request"] = _make_test_request_stub()
|
|
else:
|
|
return await func(*args, **kwargs)
|
|
request = kwargs["request"]
|
|
|
|
if getattr(request, "_deerflow_test_bypass_auth", False):
|
|
return await func(*args, **kwargs)
|
|
|
|
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:
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
|
|
|
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 and getattr(auth.user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
# Trusted internal callers (channel workers) also act for
|
|
# the connection owner carried in X-DeerFlow-Owner-User-Id.
|
|
# Scope the check to that owner instead of bypassing it; a
|
|
# leaked internal token must not grant cross-user thread
|
|
# access. The header is honored only after ``auth`` proved
|
|
# the caller holds the internal token (mirrors
|
|
# get_trusted_internal_owner_user_id, which keys off the
|
|
# middleware-stamped ``request.state.user``).
|
|
header_owner = (request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME) or "").strip()
|
|
if header_owner:
|
|
allowed = await thread_store.check_access(
|
|
thread_id,
|
|
header_owner,
|
|
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
|