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
@@ -6,6 +6,7 @@ Tests the helper methods that don't require real Dify API calls.
from __future__ import annotations
import pytest
import time
from unittest.mock import AsyncMock, MagicMock
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -18,13 +19,12 @@ class TestDifyWorkflowSubmitClient:
class FakeResponse:
status_code = 503
headers = {}
async def aread(self):
return b''
async def aiter_lines(self):
raise AssertionError('error responses must not enter the SSE loop')
yield
async def aiter_bytes(self, chunk_size=None):
del chunk_size
if False:
yield b''
class FakeStreamContext:
async def __aenter__(self):
@@ -66,6 +66,33 @@ class TestDifyWorkflowSubmitClient:
)
)
@pytest.mark.asyncio
async def test_sse_parser_rejects_an_unbounded_line(self):
from langbot.libs.dify_service_api.v1 import client, errors
class FakeResponse:
async def aiter_bytes(self, chunk_size=None):
del chunk_size
for _ in range(129):
yield b'x' * 8192
with pytest.raises(errors.DifyAPIError, match='SSE event exceeds'):
await anext(client._iter_sse_json(FakeResponse()))
@pytest.mark.asyncio
async def test_upload_rejects_oversized_local_file(self, tmp_path):
from langbot.libs.dify_service_api.v1 import client
file_path = tmp_path / 'large.bin'
file_path.write_bytes(b'x' * (client._MAX_DIFY_UPLOAD_BYTES + 1))
dify_client = client.AsyncDifyServiceClient(
'test-key',
'https://dify.example/v1',
)
with pytest.raises(ValueError, match='exceeds the size limit'):
await dify_client.upload_file(file_path, 'person_user-1')
class TestDifyExtractTextOutput:
"""Tests for _extract_dify_text_output method."""
@@ -252,42 +279,65 @@ class TestDifyHumanInputForms:
runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'})
return runner
def test_pending_forms_are_isolated_by_bot_and_pipeline(self):
def test_pending_forms_are_isolated_by_workspace_generation_bot_and_pipeline(self):
from langbot.pkg.provider.runners import difysvapi
query_a = MagicMock()
query_a.instance_uuid = 'instance-a'
query_a.workspace_uuid = 'workspace-a'
query_a.placement_generation = 1
query_a.bot_uuid = 'bot-a'
query_a.pipeline_uuid = 'pipeline-a'
query_a.session.launcher_type.value = 'person'
query_a.session.launcher_id = 'shared-user'
query_b = MagicMock()
query_b.instance_uuid = 'instance-a'
query_b.workspace_uuid = 'workspace-a'
query_b.placement_generation = 1
query_b.bot_uuid = 'bot-b'
query_b.pipeline_uuid = 'pipeline-a'
query_b.session.launcher_type.value = 'person'
query_b.session.launcher_id = 'shared-user'
query_c = MagicMock()
query_c.instance_uuid = 'instance-a'
query_c.workspace_uuid = 'workspace-a'
query_c.placement_generation = 1
query_c.bot_uuid = 'bot-a'
query_c.pipeline_uuid = 'pipeline-b'
query_c.session.launcher_type.value = 'person'
query_c.session.launcher_id = 'shared-user'
query_d = MagicMock()
query_d.instance_uuid = 'instance-a'
query_d.workspace_uuid = 'workspace-b'
query_d.placement_generation = 2
query_d.bot_uuid = 'bot-a'
query_d.pipeline_uuid = 'pipeline-a'
query_d.session.launcher_type.value = 'person'
query_d.session.launcher_id = 'shared-user'
key_a = difysvapi._session_key_from_query(query_a)
key_b = difysvapi._session_key_from_query(query_b)
key_c = difysvapi._session_key_from_query(query_c)
key_d = difysvapi._session_key_from_query(query_d)
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(key_a, {'form_token': 'token-a', 'workflow_run_id': 'run-a'})
difysvapi._set_pending_form(key_b, {'form_token': 'token-b', 'workflow_run_id': 'run-b'})
difysvapi._set_pending_form(key_c, {'form_token': 'token-c', 'workflow_run_id': 'run-c'})
difysvapi._set_pending_form(key_d, {'form_token': 'token-d', 'workflow_run_id': 'run-d'})
assert key_a != key_b
assert key_a != key_c
assert key_a != key_d
assert difysvapi._get_pending_form_by_token(key_a, 'token-a') is not None
assert difysvapi._get_pending_form_by_token(key_a, 'token-b') is None
assert difysvapi._get_pending_form_by_token(key_a, 'token-c') is None
assert difysvapi._get_pending_form_by_token(key_a, 'token-d') is None
assert difysvapi._get_pending_form_by_token(key_b, 'token-b') is not None
assert difysvapi._get_pending_form_by_token(key_c, 'token-c') is not None
assert difysvapi._get_pending_form_by_token(key_d, 'token-d') is not None
assert difysvapi._get_latest_pending_form(key_a)['workflow_run_id'] == 'run-a'
assert difysvapi._get_latest_pending_form(key_b)['workflow_run_id'] == 'run-b'
assert difysvapi._get_latest_pending_form(key_c)['workflow_run_id'] == 'run-c'
@@ -297,6 +347,130 @@ class TestDifyHumanInputForms:
assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_c)
difysvapi._PENDING_FORMS.clear()
def test_pending_form_lookup_does_not_scan_unrelated_sessions(self, monkeypatch):
from langbot.pkg.provider.runners import difysvapi
def session_key(index: int):
return (
'instance',
f'workspace-{index}',
1,
'bot',
'pipeline',
'adapter',
'person',
f'user-{index}',
)
difysvapi._PENDING_FORMS.clear()
for index in range(512):
difysvapi._set_pending_form(
session_key(index),
{
'form_token': f'token-{index}',
'workflow_run_id': f'run-{index}',
},
)
class NoGlobalIterationDict(dict):
def __iter__(self):
raise AssertionError('pending form lookup scanned all sessions')
def keys(self):
raise AssertionError('pending form lookup scanned all sessions')
def items(self):
raise AssertionError('pending form lookup scanned all sessions')
def values(self):
raise AssertionError('pending form lookup scanned all sessions')
guarded_forms = NoGlobalIterationDict(difysvapi._PENDING_FORMS)
monkeypatch.setattr(difysvapi, '_PENDING_FORMS', guarded_forms)
assert difysvapi._get_pending_form_by_token(session_key(511), 'token-511')['workflow_run_id'] == 'run-511'
difysvapi._set_pending_form(
session_key(512),
{'form_token': 'token-512', 'workflow_run_id': 'run-512'},
)
assert len(guarded_forms) == 513
def test_pending_form_expiry_heap_ignores_stale_overwrite_and_stays_bounded(self):
from langbot.pkg.provider.runners import difysvapi
session_key = (
'instance',
'workspace',
1,
'bot',
'pipeline',
'adapter',
'person',
'user',
)
difysvapi._PENDING_FORMS.clear()
now = time.time()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token',
'workflow_run_id': 'stale',
'expiration_time': now + 1,
},
)
for revision in range(500):
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token',
'workflow_run_id': f'current-{revision}',
'expiration_time': now + 3600 + revision,
},
)
difysvapi._prune_pending_forms(now + 2)
assert difysvapi._get_pending_form_by_token(session_key, 'token')['workflow_run_id'] == 'current-499'
assert difysvapi._PENDING_FORM_ACTIVE_COUNT == 1
assert len(difysvapi._PENDING_FORM_EXPIRY_HEAP) <= max(
difysvapi._PENDING_FORM_HEAP_COMPACT_FLOOR,
difysvapi._PENDING_FORM_ACTIVE_COUNT * difysvapi._PENDING_FORM_HEAP_MAX_MULTIPLIER,
)
def test_pending_form_capacity_evicts_earliest_session_without_full_scan(
self,
monkeypatch,
):
from langbot.pkg.provider.runners import difysvapi
def session_key(index: int):
return (
'instance',
f'workspace-{index}',
1,
'bot',
'pipeline',
'adapter',
'person',
f'user-{index}',
)
difysvapi._PENDING_FORMS.clear()
monkeypatch.setattr(difysvapi, '_PENDING_FORM_MAX_SESSIONS', 2)
now = time.time()
for index, expires_in in ((1, 300), (2, 100), (3, 200)):
difysvapi._set_pending_form(
session_key(index),
{
'form_token': f'token-{index}',
'expiration_time': now + expires_in,
},
)
assert session_key(1) in difysvapi._PENDING_FORMS
assert session_key(2) not in difysvapi._PENDING_FORMS
assert session_key(3) in difysvapi._PENDING_FORMS
def test_interactive_form_data_preserves_pipeline_uuid(self):
from langbot.pkg.provider.runners import difysvapi
@@ -0,0 +1,49 @@
from __future__ import annotations
import pytest
from langbot.libs.deerflow_api.client import (
ERROR_BODY_MAX_BYTES,
_read_error_body,
)
from langbot.libs.deerflow_api.errors import DeerFlowAPIError
from langbot.pkg.provider.runners.langflowapi import (
_MAX_LANGFLOW_LINE_CHARS,
_MAX_LANGFLOW_RESPONSE_BYTES,
_iter_limited_lines,
_read_limited_response,
)
class _ChunkedResponse:
def __init__(self, chunks: list[bytes]):
self._chunks = chunks
async def aiter_bytes(self, chunk_size=None):
del chunk_size
for chunk in self._chunks:
yield chunk
@pytest.mark.asyncio
async def test_langflow_rejects_oversized_stream_event():
response = _ChunkedResponse([b'x' * (_MAX_LANGFLOW_LINE_CHARS + 1)])
with pytest.raises(ValueError, match='event exceeds'):
await anext(_iter_limited_lines(response))
@pytest.mark.asyncio
async def test_langflow_rejects_oversized_blocking_response():
response = _ChunkedResponse([b'x' * (_MAX_LANGFLOW_RESPONSE_BYTES + 1)])
with pytest.raises(ValueError, match='response exceeds'):
await _read_limited_response(response)
@pytest.mark.asyncio
async def test_deerflow_rejects_oversized_error_body():
response = _ChunkedResponse([b'x' * (ERROR_BODY_MAX_BYTES + 1)])
with pytest.raises(DeerFlowAPIError, match='response exceeds'):
await _read_error_body(response)
@@ -0,0 +1,62 @@
from __future__ import annotations
import asyncio
import threading
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.provider import runner
from langbot.pkg.provider.runners import (
cozeapi,
dashscopeapi,
tboxapi,
weknoraapi,
)
@pytest.mark.asyncio
async def test_blocking_provider_iterator_runs_outside_event_loop():
release = threading.Event()
def values():
release.wait(timeout=2)
yield 'ready'
task = asyncio.create_task(anext(runner.iterate_sync(values())))
await asyncio.sleep(0)
assert not task.done()
release.set()
assert await asyncio.wait_for(task, timeout=1) == 'ready'
@pytest.mark.asyncio
async def test_sync_provider_iterator_has_event_limit():
with pytest.raises(RuntimeError, match='event limit'):
async for _ in runner.iterate_sync(iter([1, 2]), max_items=1):
pass
@pytest.mark.asyncio
async def test_coze_runner_closes_request_scoped_client():
request_runner = object.__new__(cozeapi.CozeAPIRunner)
request_runner.coze = AsyncMock()
await request_runner.aclose()
request_runner.coze.close.assert_awaited_once()
@pytest.mark.parametrize(
('append', 'exception_type'),
[
(cozeapi._append_bounded, ValueError),
(dashscopeapi._append_bounded, dashscopeapi.DashscopeAPIError),
(tboxapi._append_bounded, tboxapi.TboxAPIError),
(weknoraapi._append_bounded, weknoraapi.errors.WeKnoraAPIError),
],
)
def test_provider_accumulators_reject_oversized_output(append, exception_type):
with pytest.raises(exception_type, match='exceeds the runtime limit'):
append('x' * (1024 * 1024), 'y')