mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +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>
146 lines
5.5 KiB
Python
146 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, Mock
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
|
|
from langbot.pkg.persistence.tenant_uow import PersistenceScopeKind
|
|
from langbot.pkg.pipeline.controller import Controller
|
|
from langbot.pkg.workspace.errors import WorkspaceGenerationMismatchError
|
|
|
|
|
|
def _prepare_scheduler(mock_app):
|
|
query_pool = MagicMock()
|
|
query_pool.remove_query = AsyncMock(return_value=True)
|
|
query_pool.__aenter__ = AsyncMock(return_value=query_pool)
|
|
query_pool.__aexit__ = AsyncMock(return_value=None)
|
|
query_pool.condition = SimpleNamespace(notify_all=Mock())
|
|
mock_app.query_pool = query_pool
|
|
|
|
session = SimpleNamespace(_semaphore=SimpleNamespace(release=Mock()))
|
|
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
|
mock_app.pipeline_mgr = SimpleNamespace(get_pipeline_by_uuid=AsyncMock())
|
|
return query_pool, session
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_controller_drops_stale_query_before_pipeline_lookup(
|
|
mock_app,
|
|
sample_query,
|
|
):
|
|
query_pool, session = _prepare_scheduler(mock_app)
|
|
mock_app.workspace_service.get_execution_binding.side_effect = WorkspaceGenerationMismatchError('stale generation')
|
|
controller = Controller(mock_app)
|
|
initial_slots = controller.semaphore._value
|
|
|
|
await controller._process_query(sample_query)
|
|
|
|
mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
|
'test-workspace',
|
|
expected_generation=1,
|
|
)
|
|
mock_app.pipeline_mgr.get_pipeline_by_uuid.assert_not_awaited()
|
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
|
session._semaphore.release.assert_called_once_with()
|
|
query_pool.condition.notify_all.assert_called_once_with()
|
|
assert controller.semaphore._value == initial_slots
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloud_controller_releases_database_connection_during_pipeline_wait(
|
|
tmp_path,
|
|
mock_app,
|
|
sample_query,
|
|
):
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "pipeline-short-scope.db"}')
|
|
table = sa.Table('pipeline_scope_probe', sa.MetaData(), sa.Column('id', sa.Integer, primary_key=True))
|
|
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
|
|
manager.db = SimpleNamespace(get_engine=lambda: engine)
|
|
checked_out = 0
|
|
|
|
def on_checkout(*_args):
|
|
nonlocal checked_out
|
|
checked_out += 1
|
|
|
|
def on_checkin(*_args):
|
|
nonlocal checked_out
|
|
checked_out -= 1
|
|
|
|
sa.event.listen(engine.sync_engine, 'checkout', on_checkout)
|
|
sa.event.listen(engine.sync_engine, 'checkin', on_checkin)
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(table.metadata.create_all)
|
|
|
|
_prepare_scheduler(mock_app)
|
|
mock_app.persistence_mgr = manager
|
|
pipeline_waiting = asyncio.Event()
|
|
release_pipeline = asyncio.Event()
|
|
|
|
async def get_binding(*_args, **_kwargs):
|
|
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
|
assert manager.current_session() is None
|
|
await manager.execute_async(sa.select(table.c.id))
|
|
assert manager.current_session() is None
|
|
return SimpleNamespace(
|
|
instance_uuid='test-instance',
|
|
workspace_uuid='test-workspace',
|
|
placement_generation=1,
|
|
)
|
|
|
|
async def run_pipeline(_query):
|
|
await manager.execute_async(sa.select(table.c.id))
|
|
assert manager.current_session() is None
|
|
pipeline_waiting.set()
|
|
await release_pipeline.wait()
|
|
assert manager.current_scope().kind is PersistenceScopeKind.WORKSPACE
|
|
assert manager.current_session() is None
|
|
|
|
runtime_pipeline = SimpleNamespace(run=AsyncMock(side_effect=run_pipeline))
|
|
|
|
async def get_pipeline(*_args, **_kwargs):
|
|
await manager.execute_async(sa.select(table.c.id))
|
|
assert manager.current_session() is None
|
|
return runtime_pipeline
|
|
|
|
mock_app.workspace_service.get_execution_binding = AsyncMock(side_effect=get_binding)
|
|
mock_app.pipeline_mgr.get_pipeline_by_uuid = AsyncMock(side_effect=get_pipeline)
|
|
controller = Controller(mock_app)
|
|
|
|
task = asyncio.create_task(controller._process_query(sample_query))
|
|
await asyncio.wait_for(pipeline_waiting.wait(), timeout=2)
|
|
assert checked_out == 0
|
|
assert not task.done()
|
|
release_pipeline.set()
|
|
await asyncio.wait_for(task, timeout=2)
|
|
assert checked_out == 0
|
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_controller_revalidates_generation_before_running_pipeline(
|
|
mock_app,
|
|
sample_query,
|
|
):
|
|
query_pool, session = _prepare_scheduler(mock_app)
|
|
runtime_pipeline = SimpleNamespace(run=AsyncMock())
|
|
mock_app.pipeline_mgr.get_pipeline_by_uuid.return_value = runtime_pipeline
|
|
controller = Controller(mock_app)
|
|
|
|
await controller._process_query(sample_query)
|
|
|
|
mock_app.workspace_service.get_execution_binding.assert_awaited_once_with(
|
|
'test-workspace',
|
|
expected_generation=1,
|
|
)
|
|
runtime_pipeline.run.assert_awaited_once_with(sample_query)
|
|
query_pool.remove_query.assert_awaited_once_with(sample_query)
|
|
session._semaphore.release.assert_called_once_with()
|