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>
This commit is contained in:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
+276 -59
View File
@@ -8,13 +8,80 @@ from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction, RuntimeToLangBotAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.storage.mgr import StorageMgr
def make_handler(app):
TEST_EXECUTION_CONTEXT = ExecutionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
def canonical_binary_key(owner_type: str, owner: str, key: str) -> str:
return StorageMgr.canonical_binary_storage_key(
TEST_EXECUTION_CONTEXT,
owner_type=owner_type,
owner=owner,
key=key,
)
def make_handler(app, workspace_context: ActionContext | None = None):
"""Create a RuntimeConnectionHandler with mocked external connection."""
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
return RuntimeConnectionHandler(Mock(), AsyncMock(return_value=True), app)
workspace_context = workspace_context or ActionContext(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
)
app.workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid=workspace_context.instance_uuid,
workspace_uuid=workspace_context.workspace_uuid,
placement_generation=workspace_context.placement_generation,
)
)
)
runtime_handler = RuntimeConnectionHandler(
Mock(),
AsyncMock(return_value=True),
app,
)
installation_binding = InstallationBinding(
**workspace_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_binding,
plugin_author='test-author',
plugin_name='test-plugin',
)
runtime_handler._current_action_context.set(installation_binding)
query_pool = getattr(app, 'query_pool', None)
if query_pool is not None and hasattr(query_pool, 'cached_queries'):
def scoped_query(query):
if query is not None:
query.instance_uuid = workspace_context.instance_uuid
query.workspace_uuid = workspace_context.workspace_uuid
query.placement_generation = workspace_context.placement_generation
return query
query_pool.get_query = AsyncMock(
side_effect=lambda workspace_uuid, query_uuid: scoped_query(query_pool.cached_queries.get(query_uuid))
)
query_pool.get_query_by_legacy_id = AsyncMock(
side_effect=lambda workspace_uuid, query_id: scoped_query(query_pool.cached_queries.get(query_id))
)
return runtime_handler
def make_result(first_item=None):
@@ -34,6 +101,8 @@ class TestRagRerankAction:
def app(self):
mock_app = Mock()
mock_app.model_mgr = Mock()
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result(SimpleNamespace(uuid='rerank-1')))
mock_app.logger = Mock()
return mock_app
@@ -63,12 +132,16 @@ class TestRagRerankAction:
assert response.code == 0
assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}]
app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with('rerank-1')
app.model_mgr.get_rerank_model_by_uuid.assert_awaited_once_with(
TEST_EXECUTION_CONTEXT,
'rerank-1',
)
provider.invoke_rerank.assert_awaited_once_with(
model=rerank_model,
query='hello',
documents=['a', 'b'],
extra_args={'return_documents': False},
execution_context=TEST_EXECUTION_CONTEXT,
)
@pytest.mark.asyncio
@@ -101,13 +174,10 @@ class TestInitializePluginSettings:
return mock_app
@pytest.mark.asyncio
async def test_creates_new_setting_when_not_exists(self, app):
"""New plugin settings use default enabled, priority and config values."""
async def test_rejects_desired_installation_when_setting_not_exists(self, app):
"""A desired-state worker cannot create an unowned Core setting row."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
Mock(),
]
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -118,33 +188,23 @@ class TestInitializePluginSettings:
}
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert insert_params == {
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
'install_source': 'local',
'install_info': {'path': '/test'},
'enabled': True,
'priority': 0,
'config': {},
}
assert response.code != 0
assert 'Plugin installation setting was not found' in response.message
app.persistence_mgr.execute_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_inherits_values_from_existing_setting(self, app):
"""Existing settings are replaced while preserving user-controlled values."""
async def test_existing_desired_setting_remains_core_owned(self, app):
"""Runtime initialization validates identity without rewriting Core state."""
runtime_handler = make_handler(app)
existing_setting = SimpleNamespace(
enabled=False,
priority=5,
config={'key': 'value'},
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.side_effect = [
make_result(existing_setting),
Mock(),
Mock(),
]
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -156,13 +216,7 @@ class TestInitializePluginSettings:
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert insert_params['enabled'] is False
assert insert_params['priority'] == 5
assert insert_params['config'] == {'key': 'value'}
assert insert_params['install_source'] == 'github'
assert insert_params['install_info'] == {'repo': 'author/name'}
app.persistence_mgr.execute_async.assert_awaited_once()
class TestSetBinaryStorage:
@@ -203,7 +257,7 @@ class TestSetBinaryStorage:
)
assert response.code != 0
assert '2048 > 1024 bytes' in response.message
assert '1024-byte limit' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
@@ -218,7 +272,12 @@ class TestSetBinaryStorage:
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert insert_params['unique_key'] == 'plugin:test-owner:test-key'
assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
assert insert_params['value'] == b'x' * 512
@pytest.mark.asyncio
@@ -231,7 +290,15 @@ class TestSetBinaryStorage:
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
select_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[0].args[0])
update_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
expected_key = canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
assert expected_key in select_params.values()
assert expected_key in update_params.values()
assert update_params['value'] == b'new'
@pytest.mark.asyncio
@@ -245,21 +312,25 @@ class TestSetBinaryStorage:
)
assert response.code != 0
assert '10485761 > 10485760 bytes' in response.message
assert '10485760' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_negative_limit_disables_size_check(self, app):
"""Negative max_value_bytes allows values larger than the normal default."""
async def test_negative_limit_falls_back_to_bounded_default(self, app, monkeypatch):
"""Negative max_value_bytes cannot disable the process memory boundary."""
import langbot.pkg.plugin.handler as handler_module
runtime_handler = make_handler(app)
app.instance_config.data['plugin']['binary_storage']['max_value_bytes'] = -1
monkeypatch.setattr(handler_module, '_DEFAULT_BINARY_STORAGE_VALUE_BYTES', 1024)
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](
self.payload(b'x' * 2048)
)
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2
assert response.code != 0
assert '1024-byte limit' in response.message
app.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.asyncio
async def test_zero_limit_rejects_non_empty_values(self, app):
@@ -285,26 +356,18 @@ class TestGetPluginSettings:
return mock_app
@pytest.mark.asyncio
async def test_returns_defaults_when_setting_not_found(self, app):
"""Default plugin settings are returned when no persisted row exists."""
async def test_rejects_desired_installation_when_setting_not_found(self, app):
"""A desired-state worker cannot synthesize settings for a missing row."""
runtime_handler = make_handler(app)
app.persistence_mgr.execute_async.return_value = make_result()
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
}
)
assert response.code == 0
assert response.data == {
'enabled': True,
'priority': 0,
'plugin_config': {},
'install_source': 'local',
'install_info': {},
}
with pytest.raises(ValueError, match='Plugin installation setting was not found'):
await runtime_handler.actions[RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value](
{
'plugin_author': 'test-author',
'plugin_name': 'test-plugin',
}
)
@pytest.mark.asyncio
async def test_returns_actual_values_when_setting_exists(self, app):
@@ -316,6 +379,9 @@ class TestGetPluginSettings:
config={'custom': 'config'},
install_source='github',
install_info={'repo': 'test/repo'},
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.return_value = make_result(setting)
@@ -333,9 +399,93 @@ class TestGetPluginSettings:
'plugin_config': {'custom': 'config'},
'install_source': 'github',
'install_info': {'repo': 'test/repo'},
'installation_uuid': '00000000-0000-4000-8000-000000000001',
'runtime_revision': 1,
'artifact_digest': 'a' * 64,
}
class TestGetConfigFile:
"""Plugin config files remain bound to the trusted runtime placement."""
WORKSPACE_A = '11111111-1111-4111-8111-111111111111'
WORKSPACE_B = '22222222-2222-4222-8222-222222222222'
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock()
mock_app.storage_mgr = StorageMgr(mock_app)
mock_app.storage_mgr.storage_provider = SimpleNamespace(
load_bounded=AsyncMock(return_value=b'plugin config bytes')
)
mock_app.logger = Mock()
return mock_app
@staticmethod
def object_key(
*,
workspace_uuid: str = WORKSPACE_A,
placement_generation: int = 1,
owner_type: str = 'plugin_config',
) -> str:
return StorageMgr.scoped_object_key(
ExecutionContext(
instance_uuid='instance-a',
workspace_uuid=workspace_uuid,
placement_generation=placement_generation,
),
owner_type=owner_type,
owner=workspace_uuid,
key='config.json',
)
async def invoke(self, app, file_key: str):
runtime_handler = make_handler(
app,
ActionContext(
instance_uuid='instance-a',
workspace_uuid=self.WORKSPACE_A,
placement_generation=1,
),
)
app.persistence_mgr.execute_async.return_value = make_result(
SimpleNamespace(config={'uploaded_file': file_key})
)
return await runtime_handler.actions[PluginToRuntimeAction.GET_CONFIG_FILE.value]({'file_key': file_key})
@pytest.mark.asyncio
async def test_loads_config_file_from_same_workspace_generation(self, app):
file_key = self.object_key()
response = await self.invoke(app, file_key)
assert response.code == 0
assert base64.b64decode(response.data['file_base64']) == b'plugin config bytes'
app.storage_mgr.storage_provider.load_bounded.assert_awaited_once_with(
file_key,
max_bytes=10 * 1024 * 1024,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'file_key',
[
object_key.__func__(workspace_uuid=WORKSPACE_B),
object_key.__func__(placement_generation=2),
object_key.__func__(owner_type='upload_document'),
],
ids=['other-workspace', 'stale-generation', 'wrong-owner-type'],
)
async def test_rejects_config_file_outside_trusted_scope(self, app, file_key):
response = await self.invoke(app, file_key)
assert response.code != 0
assert 'Failed to load config file' in response.message
app.storage_mgr.storage_provider.load_bounded.assert_not_awaited()
class TestGetBinaryStorage:
"""Tests for get_binary_storage action handler."""
@@ -364,6 +514,16 @@ class TestGetBinaryStorage:
assert response.data == {
'value_base64': base64.b64encode(b'test binary content').decode('utf-8'),
}
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert 'workspace-a' in statement_params.values()
assert (
canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
in statement_params.values()
)
@pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app):
@@ -383,6 +543,63 @@ class TestGetBinaryStorage:
assert 'Storage with key test-key not found' in response.message
class TestDeleteAndListBinaryStorage:
"""Delete/list remain fenced to the trusted canonical owner scope."""
@pytest.fixture
def app(self):
mock_app = Mock()
mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.execute_async = AsyncMock()
return mock_app
@pytest.mark.asyncio
async def test_delete_uses_workspace_and_canonical_unique_key(self, app):
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'forged-owner',
}
)
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert 'workspace-a' in statement_params.values()
assert (
canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
in statement_params.values()
)
assert 'forged-owner' not in statement_params.values()
@pytest.mark.asyncio
async def test_list_keys_uses_trusted_plugin_owner(self, app):
result = Mock()
result.scalars.return_value.all.return_value = ['first', 'second']
app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE_KEYS.value](
{
'owner_type': 'plugin',
'owner': 'forged-owner',
}
)
assert response.code == 0
assert response.data == {'keys': ['first', 'second']}
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert 'workspace-a' in statement_params.values()
assert 'test-author/test-plugin' in statement_params.values()
assert 'forged-owner' not in statement_params.values()
class TestHandlerQueryLookup:
"""Tests for query lookup in cached_queries."""