mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* 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>
403 lines
13 KiB
Python
403 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
from langbot.pkg.entity.persistence.bot import Bot
|
|
from langbot.pkg.platform.botmgr import PlatformManager, RuntimeBot
|
|
from langbot.pkg.workspace.entities import WorkspaceExecutionBinding
|
|
from langbot.pkg.workspace.errors import WorkspaceInvariantError
|
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
|
|
|
|
WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
|
WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
|
BOT_A = '10000000-0000-0000-0000-00000000000a'
|
|
BOT_B = '10000000-0000-0000-0000-00000000000b'
|
|
|
|
|
|
def _context(workspace_uuid: str, bot_uuid: str, generation: int = 4) -> ExecutionContext:
|
|
return ExecutionContext(
|
|
instance_uuid='instance',
|
|
workspace_uuid=workspace_uuid,
|
|
placement_generation=generation,
|
|
bot_uuid=bot_uuid,
|
|
)
|
|
|
|
|
|
def _runtime(application, workspace_uuid: str, bot_uuid: str) -> RuntimeBot:
|
|
entity = SimpleNamespace(
|
|
uuid=bot_uuid,
|
|
workspace_uuid=workspace_uuid,
|
|
name='Same Name',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
return RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=SimpleNamespace(),
|
|
logger=SimpleNamespace(),
|
|
execution_context=_context(workspace_uuid, bot_uuid),
|
|
)
|
|
|
|
|
|
class _WorkspaceService:
|
|
async def get_execution_binding(self, workspace_uuid, expected_generation=None):
|
|
if workspace_uuid not in {WORKSPACE_A, WORKSPACE_B} or expected_generation != 4:
|
|
raise ValueError('stale')
|
|
return SimpleNamespace(
|
|
instance_uuid='instance',
|
|
workspace_uuid=workspace_uuid,
|
|
placement_generation=4,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager():
|
|
application = SimpleNamespace(workspace_service=_WorkspaceService())
|
|
platform_manager = PlatformManager(application)
|
|
platform_manager.bots = [
|
|
_runtime(application, WORKSPACE_A, BOT_A),
|
|
_runtime(application, WORKSPACE_B, BOT_B),
|
|
]
|
|
return platform_manager
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_lookup_cannot_guess_another_workspace_bot(manager):
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A), BOT_A) is manager.bots[0]
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_B, BOT_A), BOT_A) is None
|
|
assert await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_B), BOT_B) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_route_key_resolves_bound_runtime_and_rejects_non_opaque_input(manager):
|
|
assert await manager.resolve_public_bot(BOT_A) is manager.bots[0]
|
|
assert await manager.resolve_public_bot('Same Name') is None
|
|
assert await manager.resolve_public_bot('not-a-uuid') is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_runtime_generation_is_not_returned(manager):
|
|
with pytest.raises(ValueError, match='stale'):
|
|
await manager.get_bot_by_uuid(_context(WORKSPACE_A, BOT_A, generation=5), BOT_A)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generation_advance_shuts_down_and_prunes_old_workspace_bots():
|
|
class NoGlobalIterationDict(dict):
|
|
def __iter__(self):
|
|
raise AssertionError('generation advance scanned every bot runtime')
|
|
|
|
def items(self):
|
|
raise AssertionError('generation advance scanned every bot runtime')
|
|
|
|
def values(self):
|
|
raise AssertionError('generation advance scanned every bot runtime')
|
|
|
|
manager = PlatformManager(SimpleNamespace())
|
|
old_bot = SimpleNamespace(
|
|
workspace_uuid=WORKSPACE_A,
|
|
placement_generation=4,
|
|
enable=True,
|
|
shutdown=AsyncMock(),
|
|
)
|
|
other_bot = SimpleNamespace(
|
|
workspace_uuid=WORKSPACE_B,
|
|
placement_generation=4,
|
|
enable=True,
|
|
shutdown=AsyncMock(),
|
|
)
|
|
unrelated_bots = [
|
|
SimpleNamespace(
|
|
workspace_uuid=f'workspace-{index}',
|
|
placement_generation=4,
|
|
enable=False,
|
|
shutdown=AsyncMock(),
|
|
)
|
|
for index in range(1_000)
|
|
]
|
|
manager.bots = [old_bot, other_bot, *unrelated_bots]
|
|
old_context = _context(WORKSPACE_A, BOT_A, generation=4)
|
|
next_context = _context(WORKSPACE_A, BOT_A, generation=5)
|
|
|
|
await manager._observe_execution_context(old_context)
|
|
manager._bots_by_key = NoGlobalIterationDict(manager._bots_by_key)
|
|
await manager._observe_execution_context(next_context)
|
|
manager._bots_by_key = dict(manager._bots_by_key)
|
|
|
|
old_bot.shutdown.assert_awaited_once_with()
|
|
assert manager.bots == [other_bot, *unrelated_bots]
|
|
with pytest.raises(WorkspaceInvariantError, match='rolled back'):
|
|
await manager._observe_execution_context(old_context)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_websocket_proxy_creation_reuses_one_runtime():
|
|
created_adapters = []
|
|
|
|
class WebsocketAdapter:
|
|
def __init__(self, *_args, **_kwargs):
|
|
created_adapters.append(self)
|
|
|
|
def register_listener(self, *_args):
|
|
pass
|
|
|
|
application = SimpleNamespace(workspace_service=_WorkspaceService())
|
|
manager = PlatformManager(application)
|
|
manager.adapter_dict = {'websocket': WebsocketAdapter}
|
|
context = ExecutionContext(
|
|
instance_uuid='instance',
|
|
workspace_uuid=WORKSPACE_A,
|
|
placement_generation=4,
|
|
)
|
|
|
|
runtimes = await asyncio.gather(*(manager.get_websocket_proxy_bot(context) for _ in range(20)))
|
|
|
|
assert len(created_adapters) == 1
|
|
assert len({id(runtime) for runtime in runtimes}) == 1
|
|
assert manager.websocket_proxy_bots == {WORKSPACE_A: runtimes[0]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_websocket_proxy_cache_evicts_oldest_idle_workspace():
|
|
created_adapters = []
|
|
|
|
class WebsocketAdapter:
|
|
def __init__(self, *_args, **_kwargs):
|
|
self.kill = AsyncMock()
|
|
self.inbound_listener_tasks = set()
|
|
created_adapters.append(self)
|
|
|
|
def register_listener(self, *_args):
|
|
pass
|
|
|
|
application = SimpleNamespace(
|
|
workspace_service=_WorkspaceService(),
|
|
instance_config=SimpleNamespace(
|
|
data={
|
|
'system': {
|
|
'websocket_retention': {'max_workspace_proxies': 1},
|
|
}
|
|
}
|
|
),
|
|
)
|
|
manager = PlatformManager(application)
|
|
manager.adapter_dict = {'websocket': WebsocketAdapter}
|
|
|
|
await manager.get_websocket_proxy_bot(
|
|
ExecutionContext(
|
|
instance_uuid='instance',
|
|
workspace_uuid=WORKSPACE_A,
|
|
placement_generation=4,
|
|
)
|
|
)
|
|
second = await manager.get_websocket_proxy_bot(
|
|
ExecutionContext(
|
|
instance_uuid='instance',
|
|
workspace_uuid=WORKSPACE_B,
|
|
placement_generation=4,
|
|
)
|
|
)
|
|
|
|
created_adapters[0].kill.assert_awaited_once_with()
|
|
assert manager.websocket_proxy_bots == {WORKSPACE_B: second}
|
|
assert WORKSPACE_A not in manager._proxy_last_accessed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reload_stops_and_drops_existing_platform_runtimes():
|
|
old_bot = SimpleNamespace(enable=True, shutdown=AsyncMock())
|
|
old_proxy = SimpleNamespace(enable=True, shutdown=AsyncMock())
|
|
persistence_mgr = SimpleNamespace(
|
|
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [])),
|
|
)
|
|
application = SimpleNamespace(
|
|
logger=SimpleNamespace(info=lambda *_args: None, warning=lambda *_args: None),
|
|
persistence_mgr=persistence_mgr,
|
|
workspace_service=SimpleNamespace(),
|
|
)
|
|
manager = PlatformManager(application)
|
|
manager.bots = [old_bot]
|
|
manager.websocket_proxy_bots = {WORKSPACE_A: old_proxy}
|
|
manager._scope_generations = {('instance', WORKSPACE_A): 4}
|
|
|
|
await manager.load_bots_from_db()
|
|
|
|
old_bot.shutdown.assert_awaited_once_with()
|
|
old_proxy.shutdown.assert_awaited_once_with()
|
|
assert manager.bots == []
|
|
assert manager.websocket_proxy_bots == {}
|
|
assert manager._scope_generations == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_startup_reuses_validated_platform_binding():
|
|
class TenantUow:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
return False
|
|
|
|
class ProbeAdapter:
|
|
def __init__(self, _config, _logger):
|
|
self.listeners = []
|
|
|
|
def register_listener(self, event_type, listener):
|
|
self.listeners.append((event_type, listener))
|
|
|
|
async def kill(self):
|
|
return None
|
|
|
|
binding = WorkspaceExecutionBinding(
|
|
instance_uuid='instance',
|
|
workspace_uuid=WORKSPACE_A,
|
|
placement_generation=4,
|
|
write_fenced=False,
|
|
state='active',
|
|
)
|
|
bot = Bot(
|
|
uuid=BOT_A,
|
|
workspace_uuid=WORKSPACE_A,
|
|
name='Probe',
|
|
description='',
|
|
adapter='probe',
|
|
adapter_config={},
|
|
enable=False,
|
|
pipeline_routing_rules=[],
|
|
)
|
|
workspace_service = SimpleNamespace(
|
|
list_active_execution_bindings=AsyncMock(return_value=[binding]),
|
|
get_execution_binding=AsyncMock(
|
|
side_effect=AssertionError('startup platform loader repeated a validated binding lookup')
|
|
),
|
|
)
|
|
application = SimpleNamespace(
|
|
logger=SimpleNamespace(
|
|
info=lambda *_args, **_kwargs: None,
|
|
warning=lambda *_args, **_kwargs: None,
|
|
error=lambda *_args, **_kwargs: None,
|
|
),
|
|
persistence_mgr=SimpleNamespace(
|
|
mode=SimpleNamespace(value='cloud_runtime'),
|
|
tenant_uow=lambda _workspace_uuid: TenantUow(),
|
|
execute_async=AsyncMock(return_value=SimpleNamespace(all=lambda: [bot])),
|
|
),
|
|
workspace_service=workspace_service,
|
|
)
|
|
manager = PlatformManager(application)
|
|
manager.adapter_dict = {'probe': ProbeAdapter}
|
|
|
|
await manager.load_bots_from_db()
|
|
|
|
assert len(manager.bots) == 1
|
|
workspace_service.get_execution_binding.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_bot_revalidates_its_generation_before_handling_events(manager):
|
|
runtime_bot = manager.bots[0]
|
|
|
|
await runtime_bot.assert_execution_active()
|
|
|
|
runtime_bot.placement_generation = 5
|
|
with pytest.raises(ValueError, match='stale'):
|
|
await runtime_bot.assert_execution_active()
|
|
|
|
|
|
def test_runtime_bot_rejects_workspace_mismatch():
|
|
application = SimpleNamespace()
|
|
entity = SimpleNamespace(
|
|
uuid=BOT_A,
|
|
workspace_uuid=WORKSPACE_A,
|
|
name='Bot',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
with pytest.raises(WorkspaceRequiredError):
|
|
RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=SimpleNamespace(),
|
|
logger=SimpleNamespace(),
|
|
execution_context=_context(WORKSPACE_B, BOT_A),
|
|
)
|
|
|
|
|
|
class _ScopeOnlyPersistenceManager:
|
|
mode = SimpleNamespace(value='cloud_runtime')
|
|
|
|
def __init__(self):
|
|
self.active_workspace = None
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def tenant_scope(self, workspace_uuid: str):
|
|
assert self.active_workspace is None
|
|
self.active_workspace = workspace_uuid
|
|
try:
|
|
yield
|
|
finally:
|
|
self.active_workspace = None
|
|
|
|
def current_session(self):
|
|
return None
|
|
|
|
|
|
class _ListenerAdapter:
|
|
def __init__(self):
|
|
self.listeners = {}
|
|
|
|
def register_listener(self, event_type, listener):
|
|
self.listeners[event_type] = listener
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_platform_callback_carries_scope_without_holding_database_session():
|
|
persistence_mgr = _ScopeOnlyPersistenceManager()
|
|
adapter = _ListenerAdapter()
|
|
|
|
async def push_person_message(*_args, **_kwargs):
|
|
assert persistence_mgr.active_workspace == WORKSPACE_A
|
|
assert persistence_mgr.current_session() is None
|
|
return True
|
|
|
|
application = SimpleNamespace(
|
|
persistence_mgr=persistence_mgr,
|
|
workspace_service=_WorkspaceService(),
|
|
webhook_pusher=SimpleNamespace(push_person_message=push_person_message),
|
|
)
|
|
entity = SimpleNamespace(
|
|
uuid=BOT_A,
|
|
workspace_uuid=WORKSPACE_A,
|
|
name='Bot',
|
|
enable=True,
|
|
pipeline_routing_rules=[],
|
|
use_pipeline_uuid=None,
|
|
)
|
|
logger = SimpleNamespace(info=AsyncMock(), error=AsyncMock())
|
|
runtime = RuntimeBot(
|
|
ap=application,
|
|
bot_entity=entity,
|
|
adapter=adapter,
|
|
logger=logger,
|
|
execution_context=_context(WORKSPACE_A, BOT_A),
|
|
)
|
|
await runtime.initialize()
|
|
|
|
listener = adapter.listeners[platform_events.FriendMessage]
|
|
event = SimpleNamespace(message_chain=[], sender=SimpleNamespace(id='user'))
|
|
await listener(event, adapter)
|
|
|
|
assert persistence_mgr.active_workspace is None
|
|
logger.info.assert_awaited()
|