feat(runtime): add workspace_context module + tests
新建 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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>"
|
||||
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user