mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
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:
@@ -21,10 +21,13 @@ import json
|
||||
|
||||
from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
|
||||
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
||||
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
WORKSPACE_UUID = 'workspace-a'
|
||||
|
||||
|
||||
def _create_mock_pipeline(
|
||||
pipeline_uuid: str = None,
|
||||
@@ -77,7 +80,9 @@ class TestPipelineServiceGetPipelineMetadata:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_pipeline_metadata()
|
||||
result = await service.get_pipeline_metadata(
|
||||
WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert len(result) == 4
|
||||
@@ -107,7 +112,9 @@ class TestPipelineServiceGetPipelines:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_pipelines()
|
||||
result = await service.get_pipelines(
|
||||
WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result == []
|
||||
@@ -133,7 +140,9 @@ class TestPipelineServiceGetPipelines:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_pipelines()
|
||||
result = await service.get_pipelines(
|
||||
WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
@@ -152,7 +161,7 @@ class TestPipelineServiceGetPipelines:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
await service.get_pipelines(sort_by='updated_at', sort_order='ASC')
|
||||
await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC')
|
||||
|
||||
# Verify - execute was called with sort parameters
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
@@ -181,7 +190,7 @@ class TestPipelineServiceGetPipeline:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_pipeline('test-uuid')
|
||||
result = await service.get_pipeline(WORKSPACE_UUID, 'test-uuid')
|
||||
|
||||
# Verify
|
||||
assert result is not None
|
||||
@@ -200,7 +209,7 @@ class TestPipelineServiceGetPipeline:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
result = await service.get_pipeline('nonexistent-uuid')
|
||||
result = await service.get_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
|
||||
|
||||
# Verify
|
||||
assert result is None
|
||||
@@ -229,7 +238,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
||||
await service.create_pipeline({'name': 'New Pipeline'})
|
||||
await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
|
||||
|
||||
async def test_create_pipeline_no_limit(self):
|
||||
"""Creates pipeline without limit when max_pipelines=-1."""
|
||||
@@ -258,7 +267,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
bot_uuid = await service.create_pipeline({'name': 'New Pipeline'})
|
||||
bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
|
||||
|
||||
# Verify
|
||||
assert bot_uuid is not None
|
||||
@@ -293,7 +302,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
await service.create_pipeline({'name': 'Default Pipeline'}, default=True)
|
||||
await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)
|
||||
|
||||
# Verify - execute was called
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
@@ -340,7 +349,7 @@ class TestPipelineServiceCreatePipeline:
|
||||
with patch(
|
||||
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
||||
):
|
||||
await service.create_pipeline({'name': 'New Pipeline'})
|
||||
await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
|
||||
|
||||
assert len(insert_params) == 1
|
||||
assert insert_params[0]['extensions_preferences'] == {
|
||||
@@ -394,7 +403,7 @@ class TestPipelineServiceUpdatePipeline:
|
||||
'is_default': True,
|
||||
'description': 'New description', # Not name change, so no bot_service needed
|
||||
}
|
||||
await service.update_pipeline('test-uuid', pipeline_data)
|
||||
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', pipeline_data)
|
||||
|
||||
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
|
||||
assert update_params['description'] == 'New description'
|
||||
@@ -450,7 +459,7 @@ class TestPipelineServiceUpdatePipeline:
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})
|
||||
|
||||
# Execute with name change
|
||||
await service.update_pipeline('test-uuid', {'name': 'New Name'})
|
||||
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'name': 'New Name'})
|
||||
|
||||
# Verify - bot_service.update_bot was called for each bot
|
||||
assert ap.bot_service.update_bot.call_count == 2
|
||||
@@ -478,7 +487,7 @@ class TestPipelineServiceUpdatePipeline:
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
# Execute
|
||||
await service.update_pipeline('test-uuid', {'description': 'Updated'})
|
||||
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'description': 'Updated'})
|
||||
|
||||
# Verify - conversation was cleared
|
||||
assert session.using_conversation is None
|
||||
@@ -499,10 +508,10 @@ class TestPipelineServiceDeletePipeline:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute
|
||||
await service.delete_pipeline('test-uuid')
|
||||
await service.delete_pipeline(WORKSPACE_UUID, 'test-uuid')
|
||||
|
||||
# Verify
|
||||
ap.pipeline_mgr.remove_pipeline.assert_called_once_with('test-uuid')
|
||||
ap.pipeline_mgr.remove_pipeline.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
|
||||
ap.persistence_mgr.execute_async.assert_called_once()
|
||||
|
||||
async def test_delete_pipeline_nonexistent_uuid(self):
|
||||
@@ -517,7 +526,7 @@ class TestPipelineServiceDeletePipeline:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute - should not raise
|
||||
await service.delete_pipeline('nonexistent-uuid')
|
||||
await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
|
||||
|
||||
# Verify
|
||||
ap.pipeline_mgr.remove_pipeline.assert_called_once()
|
||||
@@ -549,7 +558,7 @@ class TestPipelineServiceCopyPipeline:
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
||||
await service.copy_pipeline('original-uuid')
|
||||
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
||||
|
||||
async def test_copy_pipeline_not_found_raises(self):
|
||||
"""Raises ValueError when original pipeline not found."""
|
||||
@@ -570,8 +579,8 @@ class TestPipelineServiceCopyPipeline:
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Pipeline original-uuid not found'):
|
||||
await service.copy_pipeline('original-uuid')
|
||||
with pytest.raises(WorkspaceNotFoundError, match='Pipeline original-uuid not found'):
|
||||
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
||||
|
||||
async def test_copy_pipeline_creates_copy(self):
|
||||
"""Creates a copy with (Copy) suffix."""
|
||||
@@ -614,7 +623,7 @@ class TestPipelineServiceCopyPipeline:
|
||||
)
|
||||
|
||||
# Execute
|
||||
new_uuid = await service.copy_pipeline('original-uuid')
|
||||
new_uuid = await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
||||
|
||||
# Verify
|
||||
assert new_uuid is not None
|
||||
@@ -647,7 +656,7 @@ class TestPipelineServiceCopyPipeline:
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})
|
||||
|
||||
# Execute
|
||||
await service.copy_pipeline('original-uuid')
|
||||
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
||||
|
||||
# Verify - pipeline_mgr.load_pipeline called (copy created)
|
||||
ap.pipeline_mgr.load_pipeline.assert_called_once()
|
||||
@@ -667,8 +676,8 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
service = PipelineService(ap)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Pipeline nonexistent-uuid not found'):
|
||||
await service.update_pipeline_extensions('nonexistent-uuid', [])
|
||||
with pytest.raises(WorkspaceNotFoundError, match='Pipeline nonexistent-uuid not found'):
|
||||
await service.update_pipeline_extensions(WORKSPACE_UUID, 'nonexistent-uuid', [])
|
||||
|
||||
async def test_update_extensions_sets_plugins(self):
|
||||
"""Updates plugins in extensions_preferences."""
|
||||
@@ -715,6 +724,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
# Execute
|
||||
bound_plugins = [{'plugin_uuid': 'plugin-1'}]
|
||||
await service.update_pipeline_extensions(
|
||||
WORKSPACE_UUID,
|
||||
'test-uuid',
|
||||
bound_plugins=bound_plugins,
|
||||
enable_all_plugins=False,
|
||||
@@ -764,6 +774,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
|
||||
# Execute
|
||||
await service.update_pipeline_extensions(
|
||||
WORKSPACE_UUID,
|
||||
'test-uuid',
|
||||
bound_plugins=[],
|
||||
bound_mcp_servers=['mcp-server-1'],
|
||||
@@ -811,7 +822,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
)
|
||||
|
||||
# Execute - bound_mcp_servers is None (not provided)
|
||||
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
|
||||
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
|
||||
|
||||
# Verify - persistence was called
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
@@ -850,7 +861,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
service = PipelineService(ap)
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
|
||||
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
|
||||
|
||||
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
|
||||
assert original_pipeline.extensions_preferences['mcp_resources'] == [
|
||||
@@ -858,6 +869,82 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
]
|
||||
|
||||
|
||||
class TestPipelineSecretRoundtrip:
|
||||
async def test_resource_view_redacts_runner_secrets_without_mutating_serialized_data(self):
|
||||
raw = {
|
||||
'uuid': 'pipeline-secret',
|
||||
'config': {
|
||||
'ai': {
|
||||
'n8n': {
|
||||
'webhook-url': 'https://hook.invalid/bearer-secret',
|
||||
'headers': {'Authorization': 'Bearer secret'},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
pipeline = _create_mock_pipeline(pipeline_uuid='pipeline-secret')
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(
|
||||
execute_async=AsyncMock(return_value=_create_mock_result([pipeline])),
|
||||
serialize_model=Mock(return_value=raw),
|
||||
)
|
||||
)
|
||||
|
||||
redacted = await PipelineService(ap).get_pipelines(WORKSPACE_UUID)
|
||||
|
||||
assert redacted[0]['config']['ai']['n8n']['webhook-url'] == '***'
|
||||
assert redacted[0]['config']['ai']['n8n']['headers']['Authorization'] == '***'
|
||||
assert raw['config']['ai']['n8n']['webhook-url'] == 'https://hook.invalid/bearer-secret'
|
||||
|
||||
async def test_masked_runner_config_update_restores_existing_secret(self):
|
||||
raw_config = {
|
||||
'ai': {
|
||||
'n8n': {
|
||||
'webhook-url': 'https://hook.invalid/bearer-secret',
|
||||
'headers': {'Authorization': 'Bearer secret'},
|
||||
'timeout': 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
current_pipeline = {'uuid': 'pipeline-secret', 'config': raw_config}
|
||||
write_result = Mock(rowcount=1)
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
|
||||
pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()),
|
||||
sess_mgr=SimpleNamespace(session_list=[]),
|
||||
)
|
||||
service = PipelineService(ap)
|
||||
service.get_pipeline = AsyncMock(side_effect=[current_pipeline, current_pipeline])
|
||||
|
||||
await service.update_pipeline(
|
||||
WORKSPACE_UUID,
|
||||
'pipeline-secret',
|
||||
{
|
||||
'config': {
|
||||
'ai': {
|
||||
'n8n': {
|
||||
'webhook-url': '***',
|
||||
'headers': {'Authorization': '***'},
|
||||
'timeout': 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
||||
stored_config = next(value.value for column, value in statement._values.items() if column.key == 'config')
|
||||
assert stored_config == {
|
||||
'ai': {
|
||||
'n8n': {
|
||||
'webhook-url': 'https://hook.invalid/bearer-secret',
|
||||
'headers': {'Authorization': 'Bearer secret'},
|
||||
'timeout': 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestDefaultStageOrder:
|
||||
"""Tests for default_stage_order constant."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user