mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
* feat(skills): per-user skill isolation (#2905) Implement user-scoped skill storage that isolates custom skills between users while sharing public skills globally. Key changes: - Add UserScopedSkillStorage class for per-user custom skill directories - Introduce get_or_new_user_skill_storage() factory with user_id context - Auth middleware sets effective_user_id for request-scoped storage - Agent/prompt/middleware now use user-scoped storage and prompt cache - Sandbox mounts user-scoped skill directories for search/read tools - Add validate_skill_file_path() to SkillStorage for path security - Migration script supports --all-users bulk migration - Frontend: add editable field to Skill type, error check in enableSkill - All skill categories can be toggled (custom skills default to enabled) - Update skill-creator SKILL.md with isolation-aware instructions Tests: - Add test_user_scoped_skill_storage.py (new) - Update all existing skill tests for user-scoped storage - Update sandbox, client, and router tests * fix(skills): address second-round PR review feedback (#3889) - P1-1: restrict legacy skill mount to users without custom skills - P1-2: fail-closed for _is_disabled_skill_path (OSError → return True) - P2-1: AND-merge global extensions_config skill disabled state - P2-2: atomic write for _skill_states.json (mkstemp + replace) - P2-3: normalize X-DeerFlow-Owner-User-Id in trusted boundary - P2-4: LRU-bounded _enabled_skills_by_config_cache (OrderedDict, maxsize=256) - P2-5: clear global prompt cache on PUBLIC skill toggle - P2-6: invalidate skill caches on client.update_skill * fix(tests): correct tool policy test after merge * fix(skills): use DEFAULT_SKILLS_CONTAINER_PATH in UserScopedSkillStorage The "/mnt/skills" literal in UserScopedSkillStorage.__init__ triggers test_skill_container_path_defaults::test_mnt_skills_literal_is_owned_by_skill_constants_module on CI. Migrate the default to the existing deerflow.constants constant, matching the pattern already used by LocalSkillStorage, SkillStorage, and the durable/tool_error middlewares. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
86 lines
3.3 KiB
Python
86 lines
3.3 KiB
Python
"""Authentication for trusted Gateway internal callers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from deerflow.config.paths import make_safe_user_id
|
|
from deerflow.runtime.user_context import DEFAULT_USER_ID
|
|
|
|
INTERNAL_AUTH_HEADER_NAME = "X-DeerFlow-Internal-Token"
|
|
INTERNAL_OWNER_USER_ID_HEADER_NAME = "X-DeerFlow-Owner-User-Id"
|
|
INTERNAL_AUTH_ENV_VAR = "DEER_FLOW_INTERNAL_AUTH_TOKEN"
|
|
INTERNAL_SYSTEM_ROLE = "internal"
|
|
|
|
|
|
def _load_internal_auth_token() -> str:
|
|
token = os.environ.get(INTERNAL_AUTH_ENV_VAR)
|
|
if token:
|
|
return token
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
_INTERNAL_AUTH_TOKEN = _load_internal_auth_token()
|
|
|
|
|
|
def create_internal_auth_headers(*, owner_user_id: str | None = None) -> dict[str, str]:
|
|
"""Return headers that authenticate trusted Gateway internal calls."""
|
|
headers = {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
|
|
if owner_user_id:
|
|
headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] = owner_user_id
|
|
return headers
|
|
|
|
|
|
def is_valid_internal_auth_token(token: str | None) -> bool:
|
|
"""Return True when *token* matches this Gateway worker's internal token."""
|
|
return bool(token) and secrets.compare_digest(token, _INTERNAL_AUTH_TOKEN)
|
|
|
|
|
|
def get_internal_user(owner_user_id: str | None = None):
|
|
"""Return the synthetic user used for trusted internal channel calls.
|
|
|
|
When *owner_user_id* is provided (extracted from the
|
|
``X-DeerFlow-Owner-User-Id`` header), the synthetic user's ``.id``
|
|
carries the actual channel owner instead of ``DEFAULT_USER_ID``.
|
|
This ensures that ``get_effective_user_id()`` and downstream
|
|
filesystem-path resolution (per-user custom skills, memory, thread
|
|
data) use the correct identity for IM channel messages instead of
|
|
falling back to ``"default"``.
|
|
|
|
The owner id is normalized through :func:`make_safe_user_id` so that
|
|
IM channel ids containing characters outside ``[A-Za-z0-9_-]`` (e.g.
|
|
Feishu ``open_id`` prefixed with ``ou_`` and containing underscores
|
|
that the rest of the system may treat as path separators, or
|
|
Telegram chat ids like ``-1001234567890``) cannot be used to escape
|
|
the per-user storage bucket or impersonate a different user via
|
|
header value tricks (e.g. trailing slashes, ``..`` segments). The
|
|
normalization is lossy but deterministic: two distinct raw inputs
|
|
never share a safe id, so cross-user bleed is impossible.
|
|
"""
|
|
if owner_user_id:
|
|
effective_id = make_safe_user_id(owner_user_id)
|
|
else:
|
|
effective_id = DEFAULT_USER_ID
|
|
return SimpleNamespace(id=effective_id, system_role=INTERNAL_SYSTEM_ROLE)
|
|
|
|
|
|
def get_trusted_internal_owner_user_id(request: Any) -> str | None:
|
|
"""Return the owner override for a trusted internal request, if present.
|
|
|
|
The header is ignored for normal browser/API callers. It is only honored
|
|
after ``AuthMiddleware`` has validated the internal auth token and stamped
|
|
the synthetic internal user onto ``request.state.user``.
|
|
"""
|
|
user = getattr(getattr(request, "state", None), "user", None)
|
|
if getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
|
|
return None
|
|
|
|
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
|
|
if not owner_user_id:
|
|
return None
|
|
owner_user_id = owner_user_id.strip()
|
|
return owner_user_id or None
|