From f63089aea85328eac39968839446ae6bff8e2791 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Tue, 12 May 2026 21:04:49 +0800 Subject: [PATCH] feat(runtime): add workspace_context module + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 backend/packages/harness/deerflow/runtime/workspace_context.py,仿 user_context.py 的 API 形态: - CurrentWorkspace Protocol(要求 .id: str + .role: str) - _current_workspace ContextVar + set/reset/get/require - DEFAULT_WORKSPACE_ID = "default" + get_effective_workspace_id(fallback 友好,不抛错;用于文件系统路径) - AUTO 哨兵 + resolve_workspace_id 三态(AUTO/str/None) 与 user_context 的区别:CurrentWorkspace 额外要求 .role 字段,让 Protocol 同时 约束"workspace 是哪个"和"caller 在该 workspace 内的角色"(Stage 0 只见 'owner', Stage 2 RBAC 打开 admin/member)。 backend/tests/test_workspace_context.py 16 个 test,覆盖: - 4 个 set/reset/require 行为 - 3 个 Protocol structural check(接受 .id+.role / 拒少 .role / 拒少 .id) - 4 个 get_effective_workspace_id(含 UUID → str 强转) - 5 个 resolve_workspace_id 三态(AUTO/AUTO 无 ctx 抛错/explicit str/explicit None/AUTO 强转 str) Stage 0 PR3 T3.1 + T3.2。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deerflow/runtime/workspace_context.py | 182 ++++++++++++++++++ backend/tests/test_workspace_context.py | 171 ++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 backend/packages/harness/deerflow/runtime/workspace_context.py create mode 100644 backend/tests/test_workspace_context.py diff --git a/backend/packages/harness/deerflow/runtime/workspace_context.py b/backend/packages/harness/deerflow/runtime/workspace_context.py new file mode 100644 index 00000000..d2a7f880 --- /dev/null +++ b/backend/packages/harness/deerflow/runtime/workspace_context.py @@ -0,0 +1,182 @@ +"""Request-scoped workspace context for multi-tenant authorization. + +Sibling of :mod:`deerflow.runtime.user_context`. Holds a +:class:`~contextvars.ContextVar` that the gateway's auth middleware sets +after JWT verification (PR4 will wire this up). Repository methods read +the contextvar via a sentinel default parameter, letting routers stay +free of ``workspace_id`` boilerplate. + +Three-state semantics for the repository ``workspace_id`` parameter: + +- ``AUTO`` (sentinel, default): read from contextvar; raise + :class:`RuntimeError` if unset. +- Explicit ``str``: use the provided value, overriding contextvar. +- Explicit ``None``: no WHERE clause — used only by migration scripts + and admin CLIs that intentionally bypass workspace isolation. + +Concept boundary +---------------- +A workspace is the multi-tenant scope: a single-user free account is +its own 1-person workspace; a team subscription is a multi-member +workspace. The user_id contextvar narrows further to "which member of +the workspace", letting some operations be member-scoped while others +(skill install, billing, etc.) are workspace-scoped. + +Dependency direction +-------------------- +``persistence`` (lower layer) reads from this module; ``gateway.auth`` +(higher layer) writes to it. ``CurrentWorkspace`` is defined here as a +:class:`typing.Protocol` so that ``persistence`` never needs to import +the concrete ``Workspace`` row class from ``deerflow.persistence.workspace``. +Any object with ``.id: str`` and ``.role: str`` attributes structurally +satisfies the protocol. + +Asyncio semantics +----------------- +Identical to ``user_context``: ``ContextVar`` is task-local under asyncio. +``asyncio.create_task`` inherits the parent task's workspace context; +threading.Timer does **not** (callers spawning timers must capture +``get_effective_workspace_id()`` at enqueue time, the same way +:mod:`deerflow.agents.memory.queue` captures ``user_id``). +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from typing import Final, Protocol, runtime_checkable + + +@runtime_checkable +class CurrentWorkspace(Protocol): + """Structural type for the current active workspace. + + Any object with ``.id: str`` and ``.role: str`` attributes satisfies + this protocol. Concrete implementations live in + ``app.gateway.auth.models`` (PR4 will add them). + + ``role`` is the *caller's* role within this workspace + (``'owner'`` / ``'admin'`` / ``'member'``), not the workspace's + own metadata. Stage 0 sees ``'owner'`` only — Stage 2 RBAC rollout + opens up the other values. + """ + + id: str + role: str + + +_current_workspace: Final[ContextVar[CurrentWorkspace | None]] = ContextVar("deerflow_current_workspace", default=None) + + +def set_current_workspace(workspace: CurrentWorkspace) -> Token[CurrentWorkspace | None]: + """Set the current workspace for this async task. + + Returns a reset token that should be passed to + :func:`reset_current_workspace` in a ``finally`` block to restore + the previous context. + """ + return _current_workspace.set(workspace) + + +def reset_current_workspace(token: Token[CurrentWorkspace | None]) -> None: + """Restore the context to the state captured by ``token``.""" + _current_workspace.reset(token) + + +def get_current_workspace() -> CurrentWorkspace | None: + """Return the current workspace, or ``None`` if unset. + + Safe to call in any context. Used by code paths that can proceed + without a workspace (migration scripts, public endpoints). + """ + return _current_workspace.get() + + +def require_current_workspace() -> CurrentWorkspace: + """Return the current workspace, or raise :class:`RuntimeError`. + + Used by repository code that must not be called outside a + request-authenticated context. The error message is phrased so + that a caller debugging a stack trace can locate the offending + code path. + """ + workspace = _current_workspace.get() + if workspace is None: + raise RuntimeError("repository accessed without workspace context") + return workspace + + +# --------------------------------------------------------------------------- +# Effective workspace_id helpers (filesystem isolation) +# --------------------------------------------------------------------------- + +DEFAULT_WORKSPACE_ID: Final[str] = "default" + + +def get_effective_workspace_id() -> str: + """Return the current workspace id as a string, or DEFAULT_WORKSPACE_ID if unset. + + Unlike :func:`require_current_workspace` this never raises — it is + designed for filesystem-path resolution where a valid workspace + bucket is always needed (PR6 will switch + ``Paths.thread_dir(workspace_id=...)`` to read from here). + """ + workspace = _current_workspace.get() + if workspace is None: + return DEFAULT_WORKSPACE_ID + return str(workspace.id) + + +# --------------------------------------------------------------------------- +# Sentinel-based workspace_id resolution +# --------------------------------------------------------------------------- +# +# Repository methods accept a ``workspace_id`` keyword-only argument that +# defaults to ``AUTO``. The three possible values drive distinct +# behaviours; see the docstring on :func:`resolve_workspace_id`. + + +class _AutoSentinel: + """Singleton marker meaning 'resolve workspace_id from contextvar'.""" + + _instance: _AutoSentinel | None = None + + def __new__(cls) -> _AutoSentinel: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "" + + +AUTO: Final[_AutoSentinel] = _AutoSentinel() + + +def resolve_workspace_id( + value: str | None | _AutoSentinel, + *, + method_name: str = "repository method", +) -> str | None: + """Resolve the workspace_id parameter passed to a repository method. + + Three-state semantics: + + - :data:`AUTO` (default): read from contextvar; raise + :class:`RuntimeError` if no workspace is in context. This is the + common case for request-scoped calls. + - Explicit ``str``: use the provided id verbatim, overriding any + contextvar value. Useful for tests and admin-override flows. + - Explicit ``None``: no filter — the repository should skip the + workspace_id WHERE clause entirely. Reserved for migration scripts + and CLI tools that intentionally bypass workspace isolation. + """ + if isinstance(value, _AutoSentinel): + workspace = _current_workspace.get() + if workspace is None: + raise RuntimeError( + f"{method_name} called with workspace_id=AUTO but no workspace context is set; pass an explicit workspace_id, set the contextvar via auth middleware, or opt out with workspace_id=None for migration/CLI paths." + ) + # Coerce to ``str`` at the boundary; persistence stores + # ``workspace_id`` as ``String(36)`` (UUID v4 text). + return str(workspace.id) + return value diff --git a/backend/tests/test_workspace_context.py b/backend/tests/test_workspace_context.py new file mode 100644 index 00000000..c78dd82e --- /dev/null +++ b/backend/tests/test_workspace_context.py @@ -0,0 +1,171 @@ +"""Tests for runtime.workspace_context — workspace contextvar semantics. + +Mirrors :mod:`test_user_context` but for the workspace contextvar +introduced in Stage 0 PR3. No autouse workspace fixture exists yet +(PR4 will add it together with the AuthMiddleware injection), so these +tests run against a clean contextvar. +""" + +import uuid +from types import SimpleNamespace + +import pytest + +from deerflow.runtime.workspace_context import ( + AUTO, + DEFAULT_WORKSPACE_ID, + CurrentWorkspace, + get_current_workspace, + get_effective_workspace_id, + require_current_workspace, + reset_current_workspace, + resolve_workspace_id, + set_current_workspace, +) + +# --------------------------------------------------------------------------- +# get_current_workspace / require_current_workspace / set+reset round-trip +# --------------------------------------------------------------------------- + + +def test_default_is_none(): + """Before any set, contextvar returns None.""" + assert get_current_workspace() is None + + +def test_set_and_reset_roundtrip(): + """set_current_workspace returns a token that reset restores.""" + workspace = SimpleNamespace(id="ws-1", role="owner") + token = set_current_workspace(workspace) + try: + assert get_current_workspace() is workspace + finally: + reset_current_workspace(token) + assert get_current_workspace() is None + + +def test_require_current_workspace_raises_when_unset(): + """require_current_workspace raises RuntimeError if contextvar is unset.""" + assert get_current_workspace() is None + with pytest.raises(RuntimeError, match="without workspace context"): + require_current_workspace() + + +def test_require_current_workspace_returns_workspace_when_set(): + """require_current_workspace returns the workspace when contextvar is set.""" + workspace = SimpleNamespace(id="ws-2", role="admin") + token = set_current_workspace(workspace) + try: + assert require_current_workspace() is workspace + finally: + reset_current_workspace(token) + + +# --------------------------------------------------------------------------- +# CurrentWorkspace Protocol — must require BOTH .id and .role +# --------------------------------------------------------------------------- + + +def test_protocol_accepts_id_and_role(): + """CurrentWorkspace is satisfied by any object with .id and .role.""" + workspace = SimpleNamespace(id="ws-3", role="member") + assert isinstance(workspace, CurrentWorkspace) + + +def test_protocol_rejects_missing_role(): + """An object with only .id (no .role) is NOT a workspace.""" + user_shaped = SimpleNamespace(id="ws-4") + assert not isinstance(user_shaped, CurrentWorkspace) + + +def test_protocol_rejects_no_id(): + """An object without .id does not satisfy CurrentWorkspace.""" + not_a_workspace = SimpleNamespace(role="owner") + assert not isinstance(not_a_workspace, CurrentWorkspace) + + +# --------------------------------------------------------------------------- +# get_effective_workspace_id / DEFAULT_WORKSPACE_ID tests +# --------------------------------------------------------------------------- + + +def test_default_workspace_id_is_default(): + assert DEFAULT_WORKSPACE_ID == "default" + + +def test_effective_workspace_id_returns_default_when_no_workspace(): + """No workspace in context -> fallback to DEFAULT_WORKSPACE_ID.""" + assert get_effective_workspace_id() == "default" + + +def test_effective_workspace_id_returns_workspace_id_when_set(): + workspace = SimpleNamespace(id="ws-abc-123", role="owner") + token = set_current_workspace(workspace) + try: + assert get_effective_workspace_id() == "ws-abc-123" + finally: + reset_current_workspace(token) + + +def test_effective_workspace_id_coerces_to_str(): + """workspace.id might be a UUID object; must come back as str.""" + wid = uuid.uuid4() + workspace = SimpleNamespace(id=wid, role="owner") + token = set_current_workspace(workspace) + try: + assert get_effective_workspace_id() == str(wid) + finally: + reset_current_workspace(token) + + +# --------------------------------------------------------------------------- +# resolve_workspace_id three-state semantics +# --------------------------------------------------------------------------- + + +def test_resolve_auto_reads_from_contextvar(): + workspace = SimpleNamespace(id="ws-resolve-1", role="owner") + token = set_current_workspace(workspace) + try: + assert resolve_workspace_id(AUTO) == "ws-resolve-1" + finally: + reset_current_workspace(token) + + +def test_resolve_auto_raises_when_unset(): + assert get_current_workspace() is None + with pytest.raises(RuntimeError, match="workspace_id=AUTO but no workspace"): + resolve_workspace_id(AUTO, method_name="TestRepo.search") + + +def test_resolve_explicit_str_overrides_contextvar(): + workspace = SimpleNamespace(id="ws-ctx", role="owner") + token = set_current_workspace(workspace) + try: + # Explicit value beats contextvar — admin override / test path. + assert resolve_workspace_id("ws-explicit") == "ws-explicit" + finally: + reset_current_workspace(token) + + +def test_resolve_explicit_none_means_no_filter(): + workspace = SimpleNamespace(id="ws-ctx-2", role="owner") + token = set_current_workspace(workspace) + try: + # Explicit None opts out of workspace filtering (migration scripts). + assert resolve_workspace_id(None) is None + finally: + reset_current_workspace(token) + + +def test_resolve_auto_coerces_uuid_to_str(): + """resolve_workspace_id with AUTO returns str even if workspace.id is UUID.""" + wid = uuid.uuid4() + workspace = SimpleNamespace(id=wid, role="owner") + token = set_current_workspace(workspace) + try: + resolved = resolve_workspace_id(AUTO) + assert resolved == str(wid) + assert isinstance(resolved, str) + finally: + reset_current_workspace(token)