Files
LangBot/tests/unit_tests/plugin/test_handler_tenancy.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

447 lines
14 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
from langbot_plugin.entities.io.resp import ActionResponse
from langbot_plugin.runtime.io.connection import Connection
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode
class EmptyResult:
def first(self):
return None
def all(self):
return []
class RecordingConnection(Connection):
def __init__(self):
self.sent: list[str] = []
async def send(self, message: str) -> None:
self.sent.append(message)
async def receive(self) -> str:
raise NotImplementedError
async def close(self) -> None:
return None
def workspace_context(workspace_uuid: str = 'workspace-a') -> ActionContext:
return ActionContext(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=7,
)
def make_handler(workspace_uuid: str = 'workspace-a'):
context = workspace_context(workspace_uuid)
app = SimpleNamespace(
deployment=SimpleNamespace(mode='cloud'),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=EmptyResult())),
logger=Mock(),
workspace_service=SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
)
)
),
)
runtime_handler = RuntimeConnectionHandler(
Mock(),
AsyncMock(return_value=True),
app,
)
installation_context = InstallationBinding(
**context.model_dump(exclude_none=True),
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
runtime_handler.register_installation_binding(
installation_context,
plugin_author='author-a',
plugin_name='plugin-a',
)
return runtime_handler, app, installation_context
async def invoke_with_context(
runtime_handler: RuntimeConnectionHandler,
action_context: ActionContext,
action: PluginToRuntimeAction,
data: dict,
):
token = runtime_handler._current_action_context.set(action_context)
try:
return await runtime_handler.actions[action.value](data)
finally:
runtime_handler._current_action_context.reset(token)
@pytest.mark.asyncio
async def test_plugin_action_requires_installation_capability():
runtime_handler, _app, _installation_context = make_handler()
with pytest.raises(ValueError, match='trusted Workspace context'):
await runtime_handler.actions[PluginToRuntimeAction.GET_LANGBOT_VERSION.value]({})
@pytest.mark.asyncio
async def test_runtime_action_enters_trusted_workspace_scope():
runtime_handler, app, installation_context = make_handler()
scope_events: list[tuple[str, str]] = []
@contextlib.asynccontextmanager
async def tenant_scope(workspace_uuid: str):
scope_events.append(('enter', workspace_uuid))
try:
yield SimpleNamespace()
finally:
scope_events.append(('exit', workspace_uuid))
class RecordingPersistenceManager:
def __init__(self, execute_async):
self.execute_async = execute_async
def tenant_scope(self, workspace_uuid: str):
return tenant_scope(workspace_uuid)
app.persistence_mgr = RecordingPersistenceManager(app.persistence_mgr.execute_async)
response = await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
assert response.code == 0
assert scope_events == [('enter', 'workspace-a'), ('exit', 'workspace-a')]
@pytest.mark.asyncio
async def test_blocked_llm_provider_does_not_hold_tenant_database_session():
entered = asyncio.Event()
release = asyncio.Event()
observations: list[bool] = []
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
manager = PersistenceManager(object(), mode=PersistenceMode.CLOUD_RUNTIME)
manager.db = SimpleNamespace(get_engine=lambda: engine)
runtime_handler, app, installation_context = make_handler()
app.persistence_mgr = manager
async def invoke_llm(**_kwargs):
observations.append(manager.current_session() is None)
entered.set()
await release.wait()
observations.append(manager.current_session() is None)
return SimpleNamespace(model_dump=lambda: {'role': 'assistant', 'content': 'ok'})
app.model_mgr = SimpleNamespace(
get_model_by_uuid=AsyncMock(
return_value=SimpleNamespace(
model_entity=SimpleNamespace(workspace_uuid='workspace-a'),
provider=SimpleNamespace(invoke_llm=invoke_llm),
)
)
)
runtime_handler._require_plugin_action_context = AsyncMock(return_value=(installation_context, SimpleNamespace()))
runtime_handler._require_active_action_context = AsyncMock()
runtime_handler._resource_exists = AsyncMock(return_value=True)
action = asyncio.create_task(
invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.INVOKE_LLM,
{
'llm_model_uuid': 'model-a',
'messages': [],
},
)
)
try:
await entered.wait()
assert observations == [True]
release.set()
response = await action
assert response.code == 0
assert observations == [True, True]
finally:
release.set()
if not action.done():
await action
await engine.dispose()
@pytest.mark.asyncio
async def test_plugin_action_rejects_forged_installation_capability():
runtime_handler, _app, installation_context = make_handler()
forged = installation_context.model_copy(update={'installation_uuid': 'forged-installation'})
with pytest.raises(
ValueError,
match='installation is not registered in this Workspace',
):
await invoke_with_context(
runtime_handler,
forged,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
@pytest.mark.asyncio
async def test_plugin_action_rejects_stale_workspace_generation():
runtime_handler, app, installation_context = make_handler()
app.workspace_service.get_execution_binding.side_effect = ValueError('generation is fenced')
with pytest.raises(ValueError, match='generation is fenced'):
await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
app.workspace_service.get_execution_binding.assert_awaited_once_with(
'workspace-a',
expected_generation=7,
)
@pytest.mark.asyncio
async def test_query_uuid_and_forged_payload_workspace_cannot_cross_tenants():
runtime_handler, app, installation_context = make_handler()
query_a = SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
bot_uuid='bot-a',
)
query_b = SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid='workspace-b',
placement_generation=7,
bot_uuid='bot-b',
)
async def get_query(workspace_uuid, query_uuid):
return {
('workspace-a', 'query-a'): query_a,
('workspace-b', 'query-b'): query_b,
}.get((workspace_uuid, query_uuid))
app.query_pool = SimpleNamespace(
get_query=AsyncMock(side_effect=get_query),
get_query_by_legacy_id=AsyncMock(),
)
response = await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.GET_BOT_UUID,
{
'query_id': 2,
'query_uuid': 'query-b',
'workspace_uuid': 'workspace-b',
},
)
assert response.code != 0
app.query_pool.get_query.assert_awaited_once_with('workspace-a', 'query-b')
@pytest.mark.asyncio
async def test_legacy_query_id_fallback_is_workspace_scoped():
runtime_handler, app, installation_context = make_handler()
query = SimpleNamespace(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
bot_uuid='bot-a',
)
app.query_pool = SimpleNamespace(
get_query=AsyncMock(),
get_query_by_legacy_id=AsyncMock(return_value=query),
)
response = await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.GET_BOT_UUID,
{'query_id': 19},
)
assert response.code == 0
assert response.data == {'bot_uuid': 'bot-a'}
app.query_pool.get_query_by_legacy_id.assert_awaited_once_with(
'workspace-a',
19,
)
def test_runtime_connection_is_instance_scoped_and_unbound():
runtime_handler, _app, _installation_context = make_handler()
assert runtime_handler.bound_action_context is None
def test_inbound_tenant_action_requires_complete_installation_envelope():
runtime_handler, _app, installation_context = make_handler()
assert (
runtime_handler.validate_inbound_action_context(
PluginToRuntimeAction.GET_BOTS.value,
installation_context,
)
== installation_context
)
with pytest.raises(ValueError, match='complete InstallationBinding'):
runtime_handler.validate_inbound_action_context(
PluginToRuntimeAction.GET_BOTS.value,
workspace_context('workspace-b').for_installation('installation-b'),
)
@pytest.mark.asyncio
async def test_legacy_oss_worker_capability_remains_usable_after_identity_migration():
runtime_handler, app, installation_context = make_handler()
app.deployment = SimpleNamespace(mode='oss')
setting = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=installation_context.installation_uuid,
runtime_revision=installation_context.runtime_revision,
artifact_digest=installation_context.artifact_digest,
)
result = Mock()
result.first.return_value = setting
app.persistence_mgr.execute_async.return_value = result
legacy_context = workspace_context().for_installation(installation_context.installation_uuid)
assert (
runtime_handler.validate_inbound_action_context(
PluginToRuntimeAction.GET_LANGBOT_VERSION.value,
legacy_context,
)
== legacy_context
)
response = await invoke_with_context(
runtime_handler,
legacy_context,
PluginToRuntimeAction.GET_LANGBOT_VERSION,
{},
)
assert response.code == 0
def test_installation_uuid_cannot_move_between_workspaces():
runtime_handler, _app, binding = make_handler()
moved = binding.model_copy(update={'workspace_uuid': 'workspace-b', 'runtime_revision': 2})
with pytest.raises(ValueError, match='cannot move between Workspaces'):
runtime_handler.register_installation_binding(
moved,
plugin_author='author-a',
plugin_name='plugin-a',
)
@pytest.mark.asyncio
async def test_newer_revision_fences_old_binding():
runtime_handler, _app, binding = make_handler()
newer = binding.model_copy(update={'runtime_revision': 2, 'artifact_digest': 'b' * 64})
runtime_handler.register_installation_binding(
newer,
plugin_author='author-a',
plugin_name='plugin-a',
)
with pytest.raises(ValueError, match='revision or artifact is stale'):
await runtime_handler._resolve_installation_identity(binding)
@pytest.mark.asyncio
async def test_plugin_vector_action_forwards_trusted_context_and_logical_collection():
runtime_handler, app, installation_context = make_handler()
app.rag_runtime_service = SimpleNamespace(vector_upsert=AsyncMock())
response = await invoke_with_context(
runtime_handler,
installation_context,
PluginToRuntimeAction.VECTOR_UPSERT,
{
'workspace_uuid': 'workspace-forged',
'collection_id': 'plugin-supplied-name',
'vectors': [[0.1]],
'ids': ['point-a'],
},
)
assert response.code == 0
execution_context = app.rag_runtime_service.vector_upsert.await_args.args[0]
assert execution_context == ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
)
assert app.rag_runtime_service.vector_upsert.await_args.args[1] == 'plugin-supplied-name'
@pytest.mark.asyncio
async def test_host_to_runtime_action_carries_trusted_connector_context():
app = SimpleNamespace(logger=Mock())
connection = RecordingConnection()
runtime_handler = RuntimeConnectionHandler(
connection,
AsyncMock(return_value=True),
app,
)
task = asyncio.create_task(
runtime_handler.set_runtime_config(
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='runtime-a'),
worker_policy=PluginWorkerPolicy(
max_cpus=1.0,
max_memory_mb=256,
max_pids=32,
max_open_files=64,
max_file_size_mb=128,
),
runtime_profile='oss_dev',
cloud_service_url=None,
)
)
for _ in range(10):
if connection.sent:
break
await asyncio.sleep(0)
request = json.loads(connection.sent[0])
runtime_handler.resp_waiters[request['seq_id']].set_result(ActionResponse.success({}))
await task
assert request['data']['runtime_identity'] == {
'instance_uuid': 'instance-a',
'runtime_id': 'runtime-a',
}
assert request.get('context') is None