mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
* feat(authz): add plugin resource targets, provenance, action and management enforcement
PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.
- `authz/plugin_targets.py` is the single encoder/validator for composite
targets (`namespace/part`), with one validating composer behind three
kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
`plugin_management`, plus the batch filters the page slice will consume):
disabled is a no-op, an explicit deny raises, and provider errors follow
`fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
`arequire_plugin_management` with two `app.state` resolver keys; the
Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
display (assembly descriptor, Layer-2 context later); no decision reads a
label.
- Plugin providers use a `(config signature, loop key)` cache so a
loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
`config_version` 47 -> 48 across the three mirrors.
Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.
* fix(authz): decide plugin resources from the request's config snapshot
Review round 1 on PR #5842 (willem-bd).
P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.
P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.
Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.
* fix(authz): treat a request-time config absence as unavailable
Review round 2 on PR #5842 (willem-bd).
P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.
P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.
Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.
* fix(authz): apply the running policy when the plugin config is unreadable
Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).
The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.
The rule is now "has this process loaded a configuration?", not "was the last
read successful?":
- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
`deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
*disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
failure flag of the configuration this process runs on (authorization
disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
default), so an enabled Gateway answers 403/False in the window instead of
letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
closed when nothing is loaded (round 1's rule), otherwise the loaded policy
decides.
`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.
Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.
* fix(authz): keep a denial whose reasons raise while being read
* fix(authz): intersect plugin batch filters with the candidates
The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.
* docs(plugins): map a management denial to 403 in the route example
The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.
* docs(extensions): point the 0.2.4 contract row at the PR that ships it
Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
1044 lines
43 KiB
Python
1044 lines
43 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
|
|
- memory:read - View memory data/config
|
|
- memory:write - Modify memory data (create/update/delete facts, import, clear)
|
|
- agents:read - View custom agents and the user profile
|
|
- agents:write - Create/update/delete custom agents and the user profile
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
import inspect
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from deerflow.authz.principal import build_principal_from_context
|
|
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest, Principal
|
|
from deerflow.authz.runtime import construct_authorization_provider, resolve_authorization_provider, resolve_authorization_provider_spec
|
|
from deerflow.config.authorization_config import AuthorizationConfig
|
|
|
|
if TYPE_CHECKING:
|
|
from app.gateway.auth.models import User
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
P = ParamSpec("P")
|
|
T = TypeVar("T")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 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"
|
|
# Projects
|
|
PROJECTS_READ = "projects:read"
|
|
PROJECTS_WRITE = "projects:write"
|
|
PROJECTS_DELETE = "projects:delete"
|
|
|
|
# Memory (per-user memory data surfaced by /api/memory*)
|
|
MEMORY_READ = "memory:read"
|
|
MEMORY_WRITE = "memory:write"
|
|
|
|
# Custom agents and the per-user USER.md profile (/api/agents*, /api/user-profile)
|
|
AGENTS_READ = "agents:read"
|
|
AGENTS_WRITE = "agents:write"
|
|
|
|
|
|
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)
|
|
|
|
|
|
def require_cancel_permission_if(request: Request, can_cancel: bool) -> None:
|
|
"""Require ``runs:cancel`` when a request carries cancel capability.
|
|
|
|
Cancel capability reaches the run lifecycle through more than the dedicated
|
|
cancel route: ``?action=interrupt|rollback`` on the join-stream entry and
|
|
``multitask_strategy=interrupt|rollback`` on run creation both terminate an
|
|
already-active run. A credential whose scopes omit ``runs:cancel`` (e.g. a
|
|
create- or read-only PAT) must not reach any of those paths, and
|
|
decorators cannot express query- or body-parameter-conditional
|
|
permissions, so callers apply this check where the capability is known.
|
|
|
|
``request.state.auth`` may be absent in middleware-less compositions
|
|
(unit-test stubs, auth-disabled startup); the shipped Gateway always
|
|
stamps it via ``AuthMiddleware`` before handlers run.
|
|
"""
|
|
if not can_cancel:
|
|
return
|
|
auth = getattr(request.state, "auth", None)
|
|
if auth is not None and not auth.has_permission("runs", "cancel"):
|
|
raise HTTPException(status_code=403, detail="Permission denied: runs:cancel")
|
|
|
|
|
|
_ALL_PERMISSIONS: list[str] = [
|
|
Permissions.THREADS_READ,
|
|
Permissions.THREADS_WRITE,
|
|
Permissions.THREADS_DELETE,
|
|
Permissions.RUNS_CREATE,
|
|
Permissions.RUNS_READ,
|
|
Permissions.RUNS_CANCEL,
|
|
Permissions.MEMORY_READ,
|
|
Permissions.MEMORY_WRITE,
|
|
Permissions.AGENTS_READ,
|
|
Permissions.AGENTS_WRITE,
|
|
Permissions.PROJECTS_READ,
|
|
Permissions.PROJECTS_WRITE,
|
|
Permissions.PROJECTS_DELETE,
|
|
]
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _get_route_authorization_config() -> AuthorizationConfig:
|
|
"""Return the hot-reloaded authorization config for this request.
|
|
|
|
Falls back to a disabled config when AppConfig is not available (e.g. test
|
|
environments without a config.yaml), preserving legacy all-permissions behavior.
|
|
"""
|
|
from deerflow.config.app_config import get_app_config
|
|
|
|
try:
|
|
return get_app_config().authorization
|
|
except (FileNotFoundError, RuntimeError):
|
|
return AuthorizationConfig()
|
|
|
|
|
|
# --- Provider cache (W1/F1) ---
|
|
# Keyed by config object identity (id) so the expensive model_dump() signature
|
|
# is only recomputed when get_app_config() returns a new object (hot-reload).
|
|
_route_provider_cache: dict[str, AuthorizationProvider] = {}
|
|
_route_provider_config_id: int | None = None
|
|
_route_provider_config_sig: str | None = None
|
|
|
|
|
|
def _get_cached_route_provider(config: AuthorizationConfig) -> AuthorizationProvider | None:
|
|
"""Resolve (or reuse) the authorization provider for route permissions.
|
|
|
|
The provider is cached per config object identity. When ``get_app_config()``
|
|
returns a new object (hot-reload), the signature is recomputed and compared;
|
|
only an actual content change triggers re-resolution. This avoids calling
|
|
``model_dump()`` on every request — the fast path is a single ``id()`` check.
|
|
"""
|
|
global _route_provider_config_id, _route_provider_config_sig, _route_provider_cache
|
|
|
|
config_id = id(config)
|
|
|
|
# Fast path: same config object as last time → return cached provider.
|
|
if config_id == _route_provider_config_id and _route_provider_cache:
|
|
return _route_provider_cache.get("provider")
|
|
|
|
# Config object changed (hot-reload): compute signature to check if
|
|
# content actually changed or just the wrapper object identity.
|
|
sig = repr(sorted(config.model_dump().items()))
|
|
if sig == _route_provider_config_sig and _route_provider_cache:
|
|
# Same content, different object — update id, reuse provider.
|
|
_route_provider_config_id = config_id
|
|
return _route_provider_cache.get("provider")
|
|
|
|
# Content changed (or first call): re-resolve into a local first,
|
|
# then publish id + sig + provider together to avoid a race window.
|
|
_route_provider_cache.clear()
|
|
|
|
provider = resolve_authorization_provider(config)
|
|
if provider is not None:
|
|
_route_provider_cache["provider"] = provider
|
|
_route_provider_config_id = config_id
|
|
_route_provider_config_sig = sig
|
|
return provider
|
|
|
|
|
|
async def resolve_route_permissions(user: User, *, is_internal: bool) -> list[str]:
|
|
"""Return the route permissions granted to an authenticated user.
|
|
|
|
Disabled authorization preserves the legacy all-permissions behavior.
|
|
When enabled, every registered ``resource:action`` permission is evaluated
|
|
independently so a provider failure affects only the route being checked.
|
|
Provider instances are cached per config signature (hot-reload safe).
|
|
"""
|
|
config = _get_route_authorization_config()
|
|
if config.enabled is not True:
|
|
return list(_ALL_PERMISSIONS)
|
|
|
|
try:
|
|
provider = _get_cached_route_provider(config)
|
|
if provider is None:
|
|
raise ValueError("authorization is enabled but provider resolution returned None")
|
|
except Exception:
|
|
logger.warning("Failed to resolve authorization provider for Gateway routes", exc_info=True)
|
|
return [] if config.fail_closed else list(_ALL_PERMISSIONS)
|
|
|
|
# Align with Phase 1B's tool path: internal callers (IM channel workers,
|
|
# scheduler) have system_role="internal", which is not a real RBAC role.
|
|
# Omit it so default_role applies, mirroring inject_authenticated_user_context
|
|
# which pops user_role for internal callers without a resolved owner.
|
|
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
|
|
|
|
user_role = getattr(user, "system_role", None)
|
|
if user_role == INTERNAL_SYSTEM_ROLE:
|
|
user_role = None
|
|
|
|
principal = build_principal_from_context(
|
|
{
|
|
"user_id": str(user.id),
|
|
"user_role": user_role,
|
|
"oauth_provider": getattr(user, "oauth_provider", None),
|
|
"oauth_id": getattr(user, "oauth_id", None),
|
|
"is_internal": is_internal,
|
|
},
|
|
default_role=config.default_role,
|
|
)
|
|
|
|
# Evaluate all permissions in parallel (W2).
|
|
async def _evaluate(permission: str) -> str | None:
|
|
_, action = permission.split(":", maxsplit=1)
|
|
request = AuthzRequest(
|
|
principal=principal,
|
|
resource="route",
|
|
action=action,
|
|
target=permission,
|
|
)
|
|
try:
|
|
decision = await provider.aauthorize(request)
|
|
if not isinstance(decision, AuthzDecision):
|
|
raise TypeError("AuthorizationProvider.aauthorize must return AuthzDecision")
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
logger.warning(
|
|
"Authorization provider failed while evaluating route permission %s",
|
|
permission,
|
|
exc_info=True,
|
|
)
|
|
return permission if not config.fail_closed else None
|
|
return permission if decision.allow else None
|
|
|
|
results = await asyncio.gather(*[_evaluate(p) for p in _ALL_PERMISSIONS])
|
|
return [p for p in results if p is not None]
|
|
|
|
|
|
async def resolve_route_permissions_for_request(request: Request, user: Any) -> list[str]:
|
|
"""Resolve the effective route permissions for a request's authenticated user.
|
|
|
|
Public wrapper pairing ``resolve_route_permissions`` with the internal-caller
|
|
heuristics of ``_is_internal_caller`` (auth source, synthetic internal role,
|
|
internal auth header), so middleware-less consumers resolve exactly what
|
|
``_authenticate`` resolves and the two cannot drift apart.
|
|
"""
|
|
return await resolve_route_permissions(user, is_internal=_is_internal_caller(request, user))
|
|
|
|
|
|
class _AuthorizationUnavailable(Exception):
|
|
"""Raised internally when the provider cannot be resolved for a route check.
|
|
|
|
Carries the ``fail_closed`` flag so the caller can decide between deny-all
|
|
and legacy allow-all without re-reading config.
|
|
"""
|
|
|
|
def __init__(self, *, fail_closed: bool) -> None:
|
|
self.fail_closed = fail_closed
|
|
|
|
|
|
def _resolve_route_scoped_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
|
|
"""Return ``(provider, principal)`` for route-level resource authorization.
|
|
|
|
When authorization is disabled, returns ``(None, None)`` so callers can
|
|
short-circuit to legacy behavior (resource visible). When enabled,
|
|
resolves the cached provider and builds a Principal identical to
|
|
``resolve_route_permissions`` (including the ``INTERNAL_SYSTEM_ROLE``
|
|
→ ``None`` pop so internal callers fall under ``default_role``).
|
|
|
|
Raises ``_AuthorizationUnavailable`` (carrying the ``fail_closed`` flag)
|
|
when the provider cannot be resolved; callers translate that into the
|
|
appropriate deny response (empty list / 403).
|
|
"""
|
|
config = _get_route_authorization_config()
|
|
if config.enabled is not True:
|
|
return None, None
|
|
|
|
try:
|
|
provider = _get_cached_route_provider(config)
|
|
if provider is None:
|
|
raise ValueError("authorization is enabled but provider resolution returned None")
|
|
except Exception:
|
|
logger.warning("Failed to resolve authorization provider for route-level resources", exc_info=True)
|
|
raise _AuthorizationUnavailable(fail_closed=config.fail_closed)
|
|
|
|
principal = build_principal_from_context(
|
|
_route_authz_context(user, is_internal=is_internal),
|
|
default_role=config.default_role,
|
|
)
|
|
return provider, principal
|
|
|
|
|
|
def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
|
|
"""Return ``(provider, principal)`` for model-route authorization.
|
|
|
|
Delegates to ``_resolve_route_scoped_authorization``: disabled →
|
|
``(None, None)``; provider-resolution failure raises
|
|
``_AuthorizationUnavailable`` (carrying ``fail_closed``).
|
|
"""
|
|
return _resolve_route_scoped_authorization(user, is_internal=is_internal)
|
|
|
|
|
|
def authorize_model_use(user: User, model_name: str | None, *, is_internal: bool, app_config: AppConfig) -> None:
|
|
"""Enforce model:use for an explicit model or the factory's default model.
|
|
|
|
Shared by model details and caller-selected one-shot requests. Explicit
|
|
denies always reject; provider failures honor the configured failure policy.
|
|
"""
|
|
if model_name is None:
|
|
if not app_config.models:
|
|
return # The caller's existing no-model handling remains authoritative.
|
|
model_name = app_config.models[0].name
|
|
detail = f"Model '{model_name}' is not available for your role"
|
|
try:
|
|
provider, principal = resolve_model_authorization(user, is_internal=is_internal)
|
|
except _AuthorizationUnavailable:
|
|
if app_config.authorization.fail_closed:
|
|
raise HTTPException(status_code=403, detail=detail) from None
|
|
return
|
|
if provider is None or principal is None:
|
|
return
|
|
try:
|
|
decision = provider.authorize(AuthzRequest(principal=principal, resource="model", action="use", target=model_name))
|
|
if not isinstance(decision, AuthzDecision):
|
|
raise TypeError("AuthorizationProvider.authorize must return AuthzDecision")
|
|
allowed = decision.allow
|
|
except Exception:
|
|
logger.warning("Authorization provider failed while checking model:use for %s", model_name, exc_info=True)
|
|
allowed = not app_config.authorization.fail_closed
|
|
if not allowed:
|
|
raise HTTPException(status_code=403, detail=detail)
|
|
|
|
|
|
def resolve_skill_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
|
|
"""Return ``(provider, principal)`` for skill-route authorization.
|
|
|
|
Same resolution and Principal construction as ``resolve_model_authorization``
|
|
(disabled → ``(None, None)``; provider-resolution failure raises
|
|
``_AuthorizationUnavailable`` carrying ``fail_closed``). Consumers translate
|
|
that into the appropriate deny response (empty skill listing).
|
|
"""
|
|
return _resolve_route_scoped_authorization(user, is_internal=is_internal)
|
|
|
|
|
|
def _route_authz_context(user: User, *, is_internal: bool) -> dict:
|
|
"""Build the shared Principal context dict for a request-scoped user.
|
|
|
|
Applies the ``INTERNAL_SYSTEM_ROLE → None`` pop so internal callers fall
|
|
under ``default_role`` (mirrors ``inject_authenticated_user_context``).
|
|
Used by ``_resolve_route_scoped_authorization`` (model/skill routes) and
|
|
``authorize_sandbox_for_request`` so every route-level authorization path
|
|
builds the identity the same way.
|
|
"""
|
|
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
|
|
|
|
user_role = getattr(user, "system_role", None)
|
|
if user_role == INTERNAL_SYSTEM_ROLE:
|
|
user_role = None
|
|
return {
|
|
"user_id": str(user.id),
|
|
"user_role": user_role,
|
|
"oauth_provider": getattr(user, "oauth_provider", None),
|
|
"oauth_id": getattr(user, "oauth_id", None),
|
|
"is_internal": is_internal,
|
|
}
|
|
|
|
|
|
def authorize_sandbox_for_request(
|
|
user: User,
|
|
*,
|
|
is_internal: bool,
|
|
app_config: AppConfig | None,
|
|
) -> None:
|
|
"""Check ``sandbox:execute`` for a Gateway request before sandbox acquisition.
|
|
|
|
Thin wrapper over the harness-level ``authorize_sandbox_execution`` that
|
|
builds the Principal from the request-scoped ``user`` — the same identity
|
|
construction as ``resolve_model_authorization`` (including the
|
|
``INTERNAL_SYSTEM_ROLE → None`` pop). Raises
|
|
:class:`~deerflow.sandbox.exceptions.SandboxAuthorizationError` on deny or
|
|
on provider-resolution failure under ``fail_closed``; callers translate
|
|
that into skipping the sandbox sync (not an HTTP error, since the primary
|
|
operation — e.g. file upload — can proceed without it).
|
|
|
|
No-op when ``authorization.enabled`` is false.
|
|
"""
|
|
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
|
|
from deerflow.sandbox.exceptions import SandboxAuthorizationError
|
|
|
|
config = _get_route_authorization_config()
|
|
if config.enabled is not True:
|
|
return
|
|
|
|
context = _route_authz_context(user, is_internal=is_internal)
|
|
|
|
try:
|
|
authorize_sandbox_execution(
|
|
context=context,
|
|
app_config=app_config,
|
|
)
|
|
except SandboxAuthorizationError:
|
|
raise
|
|
except Exception:
|
|
# Defense-in-depth: provider resolution and authorize() errors are
|
|
# already converted to SandboxAuthorizationError (or allowed under
|
|
# fail-open) one layer down inside authorize_sandbox_execution, so this
|
|
# normally only catches config-read failures here (e.g. get_config()
|
|
# raising in a config-less environment). Those must not 500 the
|
|
# upload/artifact route — degrade per fail_closed instead.
|
|
logger.warning("Failed to resolve authorization provider for sandbox:execute", exc_info=True)
|
|
if config.fail_closed:
|
|
raise SandboxAuthorizationError(role=context.get("user_role")) from None
|
|
|
|
|
|
# --- Plugin resource authorization -------------------------------------------
|
|
#
|
|
# The plugin request paths deliberately do NOT reuse the route provider cache
|
|
# above: that cache is synchronous, constructs inline on the calling thread and
|
|
# keys on config identity alone, so a hit cannot prove the instance belongs to
|
|
# the caller's event loop. A plugin provider may be loop-affine (its
|
|
# ``__init__`` may create an asyncio client), so this cache keys on
|
|
# ``(config signature, loop key)`` and never hands a loop-built instance to a
|
|
# sync caller or to a different loop.
|
|
|
|
_PLUGIN_PROVIDER_SYNC_SLOT = object()
|
|
#: loop_key → (config signature, provider, owning loop or None for the sync slot).
|
|
_plugin_provider_cache: dict[object, tuple[str, AuthorizationProvider, asyncio.AbstractEventLoop | None]] = {}
|
|
|
|
|
|
class _PluginAuthorizationUnavailable(Exception):
|
|
"""Raised when the plugin provider cannot be resolved for a request.
|
|
|
|
Carries ``fail_closed`` so callers choose between deny and legacy allow
|
|
without re-reading config (mirrors ``_AuthorizationUnavailable``).
|
|
"""
|
|
|
|
def __init__(self, *, fail_closed: bool) -> None:
|
|
self.fail_closed = fail_closed
|
|
|
|
|
|
def _plugin_loaded_config() -> AppConfig | None:
|
|
"""The configuration this host is running on, or ``None`` if it has none.
|
|
|
|
``get_app_config()`` re-reads the file on every request (hot reload), so a
|
|
failed read needs the last successfully loaded value to tell a host that has
|
|
no configuration at all from one whose running policy just became
|
|
unreadable.
|
|
"""
|
|
from deerflow.config.app_config import peek_loaded_app_config
|
|
|
|
return peek_loaded_app_config()
|
|
|
|
|
|
def _plugin_config_failure_fail_closed() -> bool:
|
|
"""Failure flag for a plugin decision whose configuration cannot be read.
|
|
|
|
* No loaded config: the host never ran on a configuration, and the
|
|
``config.yaml``-less case returned no gate before reaching this call, so
|
|
this is a file that exists but cannot be read or validated right now. The
|
|
flag that would permit an allow is unreadable, so it fails closed
|
|
(review P1, round 1).
|
|
* A loaded config with authorization disabled: there is no gate to apply.
|
|
* A loaded config with authorization enabled: follow that policy's own
|
|
``fail_closed`` (``True`` by default), so a host that lost the config it
|
|
is running on answers like any other authorization failure instead of
|
|
reading the loss as "disabled" (review P1, round 2).
|
|
"""
|
|
loaded = _plugin_loaded_config()
|
|
if loaded is None:
|
|
return True
|
|
authz_config = getattr(loaded, "authorization", None)
|
|
if getattr(authz_config, "enabled", None) is not True:
|
|
return False
|
|
return getattr(authz_config, "fail_closed", False) is True
|
|
|
|
|
|
def _plugin_app_config() -> AppConfig | None:
|
|
"""Read the config snapshot for a plugin decision.
|
|
|
|
``None`` means this host has no configuration file *and* has never loaded
|
|
one, so there is no policy to apply — the same rule the route-scoped gates
|
|
use for environments without a ``config.yaml`` (CI runners, direct-call
|
|
tests, a host that mounts only the plugins router): authorization can only
|
|
be enabled through config.
|
|
|
|
A host running on a configuration never reads a failed load as "disabled".
|
|
Both the lost file (a hot reload, an atomic replace, a ConfigMap remount)
|
|
and a file that exists but cannot be read or validated right now propagate,
|
|
so the caller applies the failure policy of the policy it is running on
|
|
(:func:`_plugin_config_failure_fail_closed`).
|
|
"""
|
|
from deerflow.config.app_config import get_app_config
|
|
|
|
try:
|
|
return get_app_config()
|
|
except FileNotFoundError:
|
|
if _plugin_loaded_config() is None:
|
|
logger.debug("No `config.yaml` and no loaded config; the plugin authorization gate is a no-op", exc_info=True)
|
|
return None
|
|
raise
|
|
|
|
|
|
async def _plugin_app_config_async() -> AppConfig | None:
|
|
"""Off-loop :func:`_plugin_app_config` for async request paths."""
|
|
return await asyncio.to_thread(_plugin_app_config)
|
|
|
|
|
|
def _plugin_config_signature(config: AuthorizationConfig) -> str:
|
|
return repr(sorted(config.model_dump().items()))
|
|
|
|
|
|
def _store_plugin_provider(loop_key: object, loop: asyncio.AbstractEventLoop | None, signature: str, provider: AuthorizationProvider) -> None:
|
|
"""Publish one entry, dropping entries whose loop has closed (bounded growth)."""
|
|
for key, entry in list(_plugin_provider_cache.items()):
|
|
owner = entry[2]
|
|
if owner is not None and owner.is_closed():
|
|
_plugin_provider_cache.pop(key, None)
|
|
_plugin_provider_cache[loop_key] = (signature, provider, loop)
|
|
|
|
|
|
def _get_cached_plugin_provider_sync(config: AuthorizationConfig) -> AuthorizationProvider:
|
|
"""Resolve the provider for sync callers (and FastAPI ``def`` thread-pool workers)."""
|
|
signature = _plugin_config_signature(config)
|
|
cached = _plugin_provider_cache.get(_PLUGIN_PROVIDER_SYNC_SLOT)
|
|
if cached is not None and cached[0] == signature:
|
|
return cached[1]
|
|
provider = resolve_authorization_provider(config)
|
|
if provider is None:
|
|
raise ValueError("authorization is enabled but provider resolution returned None")
|
|
_store_plugin_provider(_PLUGIN_PROVIDER_SYNC_SLOT, None, signature, provider)
|
|
return provider
|
|
|
|
|
|
async def _aget_cached_plugin_provider(config: AuthorizationConfig) -> AuthorizationProvider:
|
|
"""Resolve the provider belonging to *this* loop; a miss discovers off-loop."""
|
|
loop = asyncio.get_running_loop()
|
|
loop_key = id(loop)
|
|
signature = _plugin_config_signature(config)
|
|
cached = _plugin_provider_cache.get(loop_key)
|
|
if cached is not None and cached[0] == signature:
|
|
return cached[1]
|
|
|
|
if getattr(config, "provider", None) is None:
|
|
# Nothing to import; preserve the resolver's own "enabled but unconfigured" error.
|
|
provider = resolve_authorization_provider(config)
|
|
else:
|
|
spec = await asyncio.to_thread(resolve_authorization_provider_spec, config)
|
|
# Construction stays on the calling loop: async providers may create
|
|
# loop-affine clients in ``__init__``.
|
|
provider = construct_authorization_provider(spec, config)
|
|
if provider is None:
|
|
raise ValueError("authorization is enabled but provider resolution returned None")
|
|
_store_plugin_provider(loop_key, loop, signature, provider)
|
|
return provider
|
|
|
|
|
|
def _plugin_request_principal(request: Request, authz_config: AuthorizationConfig) -> Principal | None:
|
|
"""Build the request-scoped Principal, or ``None`` when the caller is anonymous."""
|
|
user = getattr(getattr(request, "state", None), "user", None)
|
|
if user is None:
|
|
return None
|
|
return build_principal_from_context(
|
|
_route_authz_context(user, is_internal=_is_internal_caller(request, user)),
|
|
default_role=authz_config.default_role,
|
|
)
|
|
|
|
|
|
def resolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]:
|
|
"""Return ``(provider, principal, app_config)`` for plugin resources, for sync callers.
|
|
|
|
``provider is None`` means authorization is disabled for this host — the
|
|
caller keeps today's behavior; ``app_config is None`` means there is no
|
|
configuration at all. The same ``app_config`` snapshot that produced the
|
|
provider is returned so the enforcement layer never re-reads the
|
|
configuration. Raises ``_PluginAuthorizationUnavailable`` (carrying
|
|
``fail_closed``) when the configuration this host is running on became
|
|
unreadable, or when the provider cannot be resolved.
|
|
"""
|
|
try:
|
|
app_config = _plugin_app_config()
|
|
except Exception:
|
|
logger.warning("App config unavailable for plugin authorization", exc_info=True)
|
|
raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None
|
|
if app_config is None:
|
|
return None, None, None
|
|
authz_config = getattr(app_config, "authorization", None)
|
|
if getattr(authz_config, "enabled", None) is not True:
|
|
return None, None, app_config
|
|
try:
|
|
provider = _get_cached_plugin_provider_sync(authz_config)
|
|
except Exception:
|
|
logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True)
|
|
raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None
|
|
return provider, _plugin_request_principal(request, authz_config), app_config
|
|
|
|
|
|
async def aresolve_plugin_authorization(request: Request) -> tuple[AuthorizationProvider | None, Principal | None, AppConfig | None]:
|
|
"""Async ``(provider, principal, app_config)`` for plugin resources.
|
|
|
|
Never returns the sync slot, and never performs synchronous config loading,
|
|
provider discovery or provider calls on the event loop. The returned
|
|
``app_config`` is the snapshot the provider was resolved from, so the
|
|
enforcement layer never re-reads the configuration.
|
|
"""
|
|
try:
|
|
app_config = await _plugin_app_config_async()
|
|
except Exception:
|
|
logger.warning("App config unavailable for plugin authorization", exc_info=True)
|
|
raise _PluginAuthorizationUnavailable(fail_closed=_plugin_config_failure_fail_closed()) from None
|
|
if app_config is None:
|
|
return None, None, None
|
|
authz_config = getattr(app_config, "authorization", None)
|
|
if getattr(authz_config, "enabled", None) is not True:
|
|
return None, None, app_config
|
|
try:
|
|
provider = await _aget_cached_plugin_provider(authz_config)
|
|
except Exception:
|
|
logger.warning("Failed to resolve authorization provider for plugin resources", exc_info=True)
|
|
raise _PluginAuthorizationUnavailable(fail_closed=getattr(authz_config, "fail_closed", False) is True) from None
|
|
return provider, _plugin_request_principal(request, authz_config), app_config
|
|
|
|
|
|
async def authorize_plugin_action_for_request(request: Request, *, namespace: str, action_name: str) -> None:
|
|
"""Authorize a registered plugin action before its handler runs (O2).
|
|
|
|
Returns normally when the action is permitted (or authorization is
|
|
disabled); raises ``HTTPException(403)`` on deny, and on a provider
|
|
resolution failure under ``fail_closed``.
|
|
"""
|
|
from deerflow.authz.plugin_authz import PluginAuthorizationError, aenforce_plugin_action
|
|
|
|
try:
|
|
provider, principal, app_config = await aresolve_plugin_authorization(request)
|
|
except _PluginAuthorizationUnavailable as unavailable:
|
|
if unavailable.fail_closed:
|
|
raise _plugin_action_denied() from None
|
|
return
|
|
if provider is None:
|
|
# Authorization is disabled: today's behavior.
|
|
return
|
|
try:
|
|
await aenforce_plugin_action(
|
|
principal=principal,
|
|
app_config=app_config,
|
|
namespace=namespace,
|
|
action_name=action_name,
|
|
provider=provider,
|
|
)
|
|
except PluginAuthorizationError as error:
|
|
raise _plugin_action_denied() from error
|
|
|
|
|
|
def _plugin_action_denied() -> HTTPException:
|
|
return HTTPException(status_code=403, detail="Plugin action not permitted for your role.")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SandboxRequestLease:
|
|
"""One Gateway request's process-local use of a sandbox client."""
|
|
|
|
sandbox: object | None
|
|
sandbox_id: str | None
|
|
denied: bool
|
|
owner_id: str | None
|
|
provider: object | None
|
|
|
|
async def release(self) -> None:
|
|
"""Drop the request holder without bypassing concurrent executions."""
|
|
if self.owner_id is None or self.provider is None:
|
|
return
|
|
from deerflow.sandbox.lease import get_sandbox_lease_manager
|
|
|
|
owner_id = self.owner_id
|
|
self.owner_id = None
|
|
await get_sandbox_lease_manager(self.provider).release_async(owner_id)
|
|
|
|
|
|
async def try_acquire_sandbox_for_request(
|
|
request: Request,
|
|
sandbox_provider,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str,
|
|
app_config: AppConfig | None,
|
|
owner_prefix: str = "gateway",
|
|
release_on_last: bool = True,
|
|
) -> SandboxRequestLease:
|
|
"""Gate + acquire the thread sandbox for a Gateway sync path.
|
|
|
|
Single entry point for the uploads/artifacts sandbox-sync paths so the
|
|
deny/skip semantics live in one place: runs the ``sandbox:execute`` gate
|
|
for the request's user, then acquires the sandbox under a unique request
|
|
holder. Callers must await :meth:`SandboxRequestLease.release` after their
|
|
last client operation.
|
|
|
|
- denied role → no sandbox/owner and ``denied=True``: acquisition was skipped by policy;
|
|
the primary operation (upload / artifact edit) proceeds without the
|
|
sandbox copy.
|
|
- allowed → ``sandbox`` is the acquired instance, or ``sandbox is None`` when
|
|
the provider lost it right after acquiring (infrastructure error —
|
|
callers surface it as 500 / RuntimeError respectively, since that is
|
|
not a policy decision).
|
|
- ``request is None`` (direct-call tests) and unresolvable users skip the
|
|
gate — same fail-open semantics as the models routes' anonymous bypass.
|
|
"""
|
|
from deerflow.sandbox.exceptions import SandboxAuthorizationError
|
|
|
|
try:
|
|
from app.gateway.deps import get_optional_user_from_request
|
|
|
|
user = await get_optional_user_from_request(request) if request is not None else None
|
|
if user is not None:
|
|
authorize_sandbox_for_request(user, is_internal=_is_internal_caller(request, user), app_config=app_config)
|
|
except SandboxAuthorizationError:
|
|
logger.info("Sandbox sync skipped: sandbox execution not permitted for this caller (thread_id=%s)", thread_id)
|
|
return SandboxRequestLease(
|
|
sandbox=None,
|
|
sandbox_id=None,
|
|
denied=True,
|
|
owner_id=None,
|
|
provider=None,
|
|
)
|
|
|
|
from deerflow.sandbox.lease import get_sandbox_lease_manager
|
|
|
|
owner_id = f"{owner_prefix}:{uuid.uuid4()}"
|
|
sandbox_id = await get_sandbox_lease_manager(sandbox_provider).acquire_async(
|
|
owner_id,
|
|
thread_id,
|
|
user_id=user_id,
|
|
release_on_last=release_on_last,
|
|
)
|
|
return SandboxRequestLease(
|
|
sandbox=sandbox_provider.get(sandbox_id),
|
|
sandbox_id=sandbox_id,
|
|
denied=False,
|
|
owner_id=owner_id,
|
|
provider=sandbox_provider,
|
|
)
|
|
|
|
|
|
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=[])
|
|
|
|
permissions = await resolve_route_permissions_for_request(request, user)
|
|
return AuthContext(user=user, permissions=permissions)
|
|
|
|
|
|
def _is_internal_caller(request: Request, user: Any) -> bool:
|
|
"""Determine if the request originates from a trusted internal caller.
|
|
|
|
Checks three signals (any one suffices):
|
|
1. ``request.state.auth_source == AUTH_SOURCE_INTERNAL`` (set by AuthMiddleware).
|
|
2. ``user.system_role == INTERNAL_SYSTEM_ROLE`` (synthetic internal user).
|
|
3. The request carries a valid internal auth token header (decorator-only path
|
|
where AuthMiddleware may not have stamped ``auth_source`` yet).
|
|
"""
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL
|
|
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, INTERNAL_SYSTEM_ROLE, is_valid_internal_auth_token
|
|
|
|
if getattr(getattr(request, "state", None), "auth_source", None) == AUTH_SOURCE_INTERNAL:
|
|
return True
|
|
if getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
return True
|
|
# Decorator-only path: check the internal token header directly.
|
|
internal_token = request.headers.get(INTERNAL_AUTH_HEADER_NAME) if hasattr(request, "headers") else None
|
|
if internal_token and is_valid_internal_auth_token(internal_token):
|
|
return True
|
|
return False
|
|
|
|
|
|
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:
|
|
# Bind the wrapped signature so a request passed positionally
|
|
# (direct calls in unit tests, non-FastAPI callers) is honored
|
|
# instead of colliding with an injected keyword stub.
|
|
signature = inspect.signature(func)
|
|
try:
|
|
bound = signature.bind(*args, **kwargs)
|
|
except TypeError:
|
|
bound = None
|
|
request = kwargs.get("request")
|
|
if request is None and bound is not None:
|
|
request = bound.arguments.get("request")
|
|
if request is None:
|
|
# Unit tests may call decorated route handlers directly — with
|
|
# or without constructing a FastAPI Request object — and may
|
|
# pass ``request`` positionally. The full-signature bind at
|
|
# the top of this wrapper already recovered a positional
|
|
# request, so only the stub injection for handlers that
|
|
# declare ``request`` but received none remains here.
|
|
if "request" in signature.parameters:
|
|
kwargs["request"] = _make_test_request_stub()
|
|
request = kwargs["request"]
|
|
else:
|
|
return await func(*args, **kwargs)
|
|
|
|
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 and bound is not None:
|
|
thread_id = bound.arguments.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
|