Files
LangBot/tests/utils/import_isolation.py
T
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture

* Add OSS and commercial workspace boundaries

* docs: redesign multi-tenant workspace architecture

* feat(tenancy): implement workspace isolation

* docs(tenancy): record verification evidence

* docs(tenancy): revise single-instance SaaS topology

* docs(tenancy): refine architecture options

* docs: finalize cloud v2 multi-tenant decisions

* feat(tenancy): establish cloud isolation foundations

* feat(tenancy): harden shared cloud runtime boundaries

* docs(tenancy): record final isolation verification

* fix(tenancy): close isolation and permission gaps

* docs(tenancy): record final isolation verification

* feat(tenancy): connect cloud workspace control plane

* fix(build): install git for pinned SDK

* docs(cloud): update control plane verification

* chore: update multi-tenant SDK pin

* fix(cloud): skip legacy model sync during startup

* test(cloud): preserve minimal model manager fixtures

* fix(cloud): preserve authenticated account context

* fix(cloud): reuse authenticated account for user info

* feat(cloud): complete Workspace settings navigation

* test(web): cover Workspace dropdown menu

* feat(web): place workspace controls in sidebar

* refactor(web): streamline workspace controls

* style(web): format workspace layout test

* fix(cloud): surface runtime and workspace plan status

* fix(plugin): keep runtime identity stable across restarts

* fix(ui): widen and center workspace switcher

* fix(ui): hide roles from workspace switcher

* fix(ui): align workspace switcher with sidebar entries

* feat(workspace): add in-product collaboration and direct Cloud launch

* style: format collaboration changes

* fix(workspace): bind collaboration APIs to tenant UoW

* fix(cloud): preserve Core-owned collaboration state

* test(cloud): require Space identity for invite registration

* feat(cloud): complete secure invitation experience

* style(web): format invitation flows

* fix(cloud): recover box runtime without unscoped skill reload

* feat(oss): enforce invitation account and owner billing flows

* style: format OSS account service

* test(oss): cover invitation logout handoff

* fix(oss): resolve workspace owner in scoped session

* feat(cloud): harden multi-tenant runtime resources

* fix(cloud): bound runtime restart storms

* fix(cloud): eliminate periodic runtime CPU spikes

* fix(cloud): enforce instance capacity ceilings

* fix(cloud): scope public login capability discovery

* fix(cloud): bound tenant maintenance and monitoring work

* fix(runtime): bound tenant resource amplification

* fix(deps): pin green multi-tenant plugin SDK

* fix(cloud): handle unavailable skill capability

* fix(security): require authentication for image file endpoint (H-2)

- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations

* test: add comprehensive cross-tenant isolation tests

Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide

* fix(security): resolve M-1, M-2, M-3 security findings

M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.

* fix(cloud): unblock tenant CI and enforce knowledge quotas

* fix(tenancy): scope rerank model sync

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-30 21:43:35 +08:00

214 lines
7.9 KiB
Python

"""
sys.modules isolation utilities for breaking circular import chains.
Provides safe, reversible sys.modules manipulation for tests that need to
import modules with heavy import-time side effects (auto-registration,
circular dependencies, etc.).
Usage pattern:
1. Create mock objects for modules that cause circular imports
2. Use isolated_sys_modules to temporarily patch sys.modules
3. Import target module after patching
4. Test the real production code
5. Context manager automatically restores original sys.modules state
Key principle: mock only what breaks the import chain, not what the code needs.
"""
from __future__ import annotations
import sys
import enum
from contextlib import contextmanager
from typing import Generator
from unittest.mock import MagicMock
_MISSING = object()
class MockLifecycleControlScope(enum.Enum):
"""Mock enum for breaking circular import in core.entities."""
APPLICATION = 'application'
PLATFORM = 'platform'
PLUGIN = 'plugin'
PROVIDER = 'provider'
@contextmanager
def isolated_sys_modules(
mocks: dict[str, object],
clear: list[str] | None = None,
) -> Generator[None, None, None]:
"""
Context manager for isolated sys.modules manipulation.
Safely patches sys.modules with mocks and clears specified modules,
then restores original state on exit. This prevents test pollution
where mocks leak into subsequent tests.
Args:
mocks: Dict mapping module names to mock objects.
These will be set in sys.modules during the context.
clear: List of module names to remove from sys.modules before
entering the context. Useful for forcing re-import of
modules that depend on mocked modules.
Example:
>>> with isolated_sys_modules(
... mocks={'my_pkg.heavy_module': MagicMock()},
... clear=['my_pkg.target_module'],
... ):
... from my_pkg.target_module import MyClass # Safe import
Note:
- Modules in both mocks and clear will be mocked (not cleared)
- Original state is restored even if exception occurs
- Modules not in sys.modules before context are removed after
- Package attributes (e.g., my_pkg.submodule) are also saved/restored
"""
clear = clear or []
touched = set(mocks.keys()) | set(clear)
# Save original state for modules we'll touch
saved: dict[str, object] = {}
for name in touched:
if name in sys.modules:
saved[name] = sys.modules[name]
# Importing a submodule also mutates its parent package attribute. Preserve
# that state for every mocked or cleared module, otherwise restoring only
# sys.modules leaves stale class/enum identities attached to the package.
saved_attrs: dict[str, tuple[str, str, object]] = {}
for name in touched:
pkg_name, separator, attr_name = name.rpartition('.')
if separator and pkg_name in sys.modules:
saved_attrs[name] = (
pkg_name,
attr_name,
getattr(sys.modules[pkg_name], attr_name, _MISSING),
)
try:
# Clear modules first (force re-import)
for name in clear:
if name not in mocks: # Don't clear if we're mocking it
sys.modules.pop(name, None)
saved_attr = saved_attrs.get(name)
if saved_attr is not None:
pkg_name, attr_name, _ = saved_attr
if pkg_name in sys.modules and hasattr(sys.modules[pkg_name], attr_name):
delattr(sys.modules[pkg_name], attr_name)
# Apply mocks
for name, module in mocks.items():
sys.modules[name] = module
# Update package attributes to point to mocks
# This is critical because `from package import submodule` gets the attribute,
# not sys.modules directly
for mock_name, (pkg_name, attr_name) in _PACKAGE_ATTRIBUTE_UPDATES.items():
if mock_name in mocks and pkg_name in sys.modules:
setattr(sys.modules[pkg_name], attr_name, mocks[mock_name])
yield
finally:
# Restore original state - critical for test isolation
for name in touched:
if name in saved:
sys.modules[name] = saved[name]
else:
# Wasn't in sys.modules originally, remove it
sys.modules.pop(name, None)
# Restore package attributes (or remove ones that did not previously
# exist), keeping package lookup consistent with restored sys.modules.
for pkg_name, attr_name, original_value in saved_attrs.values():
if pkg_name not in sys.modules:
continue
if original_value is _MISSING:
if hasattr(sys.modules[pkg_name], attr_name):
delattr(sys.modules[pkg_name], attr_name)
else:
setattr(sys.modules[pkg_name], attr_name, original_value)
def make_pipeline_handler_import_mocks() -> dict[str, MagicMock]:
"""
Create mock objects needed to break circular import chain in handlers.
The import chain:
handler → core.app → pipeline.controller → http_controller
→ groups/plugins → taskmgr (partial init)
This function creates minimal mocks that break this chain without
affecting the handler's ability to use real pipeline.entities
(needed for ResultType enum comparisons).
Returns:
Dict mapping module names to MagicMock objects.
Note:
These mocks are intentionally minimal - they only provide what's
needed to prevent circular imports. The actual handler code uses
real imports from langbot_plugin.api and langbot.pkg.pipeline.entities.
"""
# Mock core.entities with proper Enum class
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
# Mock core.app - Application class is referenced but not instantiated
mock_app = MagicMock()
# Mock provider.runner - has preregistered_runners attribute
mock_runner = MagicMock()
mock_runner.preregistered_runners = [] # Empty by default, tests override
# Mock utils.importutil - prevents auto-import of runners
mock_importutil = MagicMock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
return {
'langbot.pkg.core.entities': mock_entities,
'langbot.pkg.core.app': mock_app,
'langbot.pkg.pipeline.controller': MagicMock(),
'langbot.pkg.pipeline.pipelinemgr': MagicMock(),
'langbot.pkg.pipeline.process.process': MagicMock(),
'langbot.pkg.provider.runner': mock_runner,
'langbot.pkg.utils.importutil': mock_importutil,
}
# Package attributes that need to be updated alongside sys.modules mocking.
# When Python imports a submodule (e.g., langbot.pkg.provider.runner), it
# automatically sets an attribute on the parent package. The import statement
# `from ....provider import runner` gets this attribute, not sys.modules directly.
# This dict maps mock module names to the parent packages that need attribute updates.
_PACKAGE_ATTRIBUTE_UPDATES: dict[str, tuple[str, str]] = {
'langbot.pkg.provider.runner': ('langbot.pkg.provider', 'runner'),
}
def get_handler_modules_to_clear(handler_name: str) -> list[str]:
"""
Get list of handler-related modules to clear before import.
These modules need to be cleared so they're re-imported after
the circular import chain is mocked. Without clearing, they'd
already be in sys.modules (possibly partially initialized).
Args:
handler_name: The handler file name (e.g., 'chat', 'command')
Returns:
List of module names to clear.
"""
return [
'langbot.pkg.pipeline.process.handler',
'langbot.pkg.pipeline.process.handlers',
f'langbot.pkg.pipeline.process.handlers.{handler_name}',
]