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
@@ -1,8 +1,13 @@
from quart import request
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
import asyncio
import base64
import binascii
import contextvars
import functools
import httpx
import json
import os
import traceback
from quart import Quart
import xml.etree.ElementTree as ET
@@ -11,9 +16,72 @@ from .wecomcsevent import WecomCSEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import aiofiles
import time
from contextlib import asynccontextmanager
from langbot.pkg.utils import httpclient
_MAX_MEDIA_BYTES = 10 * 1024 * 1024
_MAX_CALLBACK_BODY_BYTES = 1024 * 1024
async def _read_httpx_media_limited(response: httpx.Response) -> bytes:
content_length = response.headers.get('Content-Length')
if content_length is not None:
try:
if int(content_length) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
except (TypeError, ValueError) as exc:
if 'exceeds' in str(exc):
raise
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
return bytes(content)
async def _read_local_media_limited(path: str) -> bytes:
if await asyncio.to_thread(os.path.getsize, path) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
async with aiofiles.open(path, 'rb') as file:
content = await file.read(_MAX_MEDIA_BYTES + 1)
if len(content) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
return content
async def _decode_media_base64_limited(value: str) -> bytes:
max_encoded_chars = 4 * ((_MAX_MEDIA_BYTES + 2) // 3) + 4
if len(value) > max_encoded_chars:
raise ValueError('WeCom customer-service media exceeds the size limit')
content = await asyncio.to_thread(base64.b64decode, value)
if len(content) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
return content
def _bounded_token_retry(method):
"""Allow one token-refresh retry without unbounded async recursion."""
depth = contextvars.ContextVar(f'{method.__name__}_token_retry_depth', default=0)
@functools.wraps(method)
async def wrapped(*args, **kwargs):
current_depth = depth.get()
if current_depth >= 2:
raise RuntimeError(f'{method.__name__} exceeded the token refresh retry limit')
token = depth.set(current_depth + 1)
try:
return await method(*args, **kwargs)
finally:
depth.reset(token)
return wrapped
class WecomCSClient:
_CUSTOMER_CACHE_MAX = 4096
def __init__(
self,
corpid: str,
@@ -34,10 +102,12 @@ class WecomCSClient:
self.logger = logger
self.unified_mode = unified_mode
self.app = Quart(__name__)
self.app.config['MAX_CONTENT_LENGTH'] = _MAX_CALLBACK_BODY_BYTES
# Customer info cache: {external_userid: (info_dict, timestamp)}
self._customer_cache: dict[str, tuple[dict, float]] = {}
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
self._customer_cache_cleanup_at = 0.0
# 只有在非统一模式下才注册独立路由
if not self.unified_mode:
@@ -48,29 +118,40 @@ class WecomCSClient:
self._message_handlers = {
'example': [],
}
self._http_client: httpx.AsyncClient | None = None
@asynccontextmanager
async def _http_client_context(self):
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(event_hooks=httpclient.httpx_response_limit_hooks())
yield self._http_client
async def close(self) -> None:
if self._http_client is not None:
await self._http_client.aclose()
self._http_client = None
@_bounded_token_retry
async def get_pic_url(self, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}'
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.headers.get('Content-Type', '').startswith('application/json'):
data = response.json()
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_pic_url(media_id)
else:
async with self._http_client_context() as client:
async with client.stream('GET', url) as response:
image_bytes = await _read_httpx_media_limited(response)
content_type = response.headers.get('Content-Type', '')
if content_type.startswith('application/json'):
data = json.loads(image_bytes)
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_pic_url(media_id)
raise Exception('Failed to get image: ' + str(data))
# 否则是图片,转成 base64
image_bytes = response.content
content_type = response.headers.get('Content-Type', '')
base64_str = base64.b64encode(image_bytes).decode('utf-8')
base64_str = f'data:{content_type};base64,{base64_str}'
return base64_str
# 否则是图片,转成 base64
base64_str = (await asyncio.to_thread(base64.b64encode, image_bytes)).decode('utf-8')
return f'data:{content_type};base64,{base64_str}'
# access——token操作
async def check_access_token(self):
@@ -81,19 +162,20 @@ class WecomCSClient:
async def get_access_token(self, secret):
url = f'{self.base_url}/gettoken?corpid={self.corpid}&corpsecret={secret}'
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
response = await client.get(url)
data = response.json()
data = await httpclient.parse_json_response(response)
if 'access_token' in data:
return data['access_token']
else:
raise Exception(f'未获取access token: {data}')
@_bounded_token_retry
async def get_detailed_message_list(self, xml_msg: str):
# 在本方法中解析消息,并且获得消息的具体内容
if isinstance(xml_msg, bytes):
xml_msg = xml_msg.decode('utf-8')
root = ET.fromstring(xml_msg)
root = await asyncio.to_thread(ET.fromstring, xml_msg)
token = root.find('Token').text
open_kfid = root.find('OpenKfId').text
@@ -106,14 +188,14 @@ class WecomCSClient:
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/kf/sync_msg?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
params = {
'token': token,
'voice_format': 0,
'open_kfid': open_kfid,
}
response = await client.post(url, json=params)
data = response.json()
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_detailed_message_list(xml_msg)
@@ -130,11 +212,12 @@ class WecomCSClient:
# await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)
return last_msg_data
@_bounded_token_retry
async def change_service_status(self, userid: str, openkfid: str, servicer: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/kf/service_state/get?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
params = {
'open_kfid': openkfid,
'external_userid': userid,
@@ -142,18 +225,19 @@ class WecomCSClient:
'servicer_userid': servicer,
}
response = await client.post(url, json=params)
data = response.json()
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.change_service_status(userid, openkfid)
return await self.change_service_status(userid, openkfid, servicer)
if data['errcode'] != 0:
raise Exception('Failed to change service status: ' + str(data))
@_bounded_token_retry
async def send_image(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
params = {
'touser': user_id,
'toparty': '',
@@ -170,7 +254,7 @@ class WecomCSClient:
}
try:
response = await client.post(url, json=params)
data = response.json()
data = await httpclient.parse_json_response(response)
except Exception as e:
raise Exception('Failed to send image: ' + str(e))
@@ -182,6 +266,7 @@ class WecomCSClient:
if data['errcode'] != 0:
raise Exception('Failed to send image: ' + str(data))
@_bounded_token_retry
async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str, content: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
@@ -198,10 +283,10 @@ class WecomCSClient:
},
}
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = response.json()
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_text_msg(open_kfid, external_userid, msgid, content)
@@ -250,7 +335,15 @@ class WecomCSClient:
elif req.method == 'POST':
encrypt_msg = await req.data
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if len(encrypt_msg) > _MAX_CALLBACK_BODY_BYTES:
raise ValueError('WeCom customer-service callback body exceeds the size limit')
ret, xml_msg = await asyncio.to_thread(
wxcpt.DecryptMsg,
encrypt_msg,
msg_signature,
timestamp,
nonce,
)
if ret != 0:
raise Exception(f'消息解密失败,错误码: {ret}')
@@ -315,6 +408,7 @@ class WecomCSClient:
return ext
return 'jpg' # 默认返回jpg
@_bounded_token_retry
async def upload_to_work(self, image: platform_message.Image):
"""
获取 media_id
@@ -328,9 +422,8 @@ class WecomCSClient:
# 获取文件的二进制数据
if image.path:
async with aiofiles.open(image.path, 'rb') as f:
file_bytes = await f.read()
file_name = image.path.split('/')[-1]
file_bytes = await _read_local_media_limited(image.path)
file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_image_to_bytes(image.url)
file_name = image.url.split('/')[-1]
@@ -341,13 +434,15 @@ class WecomCSClient:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
file_bytes = await _decode_media_base64_limited(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
raise ValueError('image对象出错')
# 设置 multipart/form-data 格式的文件
if len(file_bytes) > _MAX_MEDIA_BYTES:
raise ValueError('WeCom customer-service media exceeds the size limit')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
@@ -361,9 +456,9 @@ class WecomCSClient:
)
# 上传文件
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
@@ -374,16 +469,17 @@ class WecomCSClient:
return media_id
async def download_image_to_bytes(self, url: str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
async with self._http_client_context() as client:
async with client.stream('GET', url) as response:
response.raise_for_status()
return await _read_httpx_media_limited(response)
# 进行media_id的获取
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id
@_bounded_token_retry
async def get_customer_info(self, external_userid: str) -> dict | None:
"""
Get customer information by external_userid with caching.
@@ -398,6 +494,11 @@ class WecomCSClient:
"""
# Check cache first
current_time = time.time()
if current_time - self._customer_cache_cleanup_at >= 30:
self._customer_cache_cleanup_at = current_time
for user_id, (_, cached_time) in tuple(self._customer_cache.items()):
if current_time - cached_time >= self._cache_ttl:
self._customer_cache.pop(user_id, None)
if external_userid in self._customer_cache:
cached_info, cached_time = self._customer_cache[external_userid]
if current_time - cached_time < self._cache_ttl:
@@ -413,9 +514,9 @@ class WecomCSClient:
'external_userid_list': [external_userid],
}
async with httpx.AsyncClient() as client:
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = response.json()
data = await httpclient.parse_json_response(response)
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
@@ -431,5 +532,10 @@ class WecomCSClient:
customer_info = customer_list[0]
# Store in cache
self._customer_cache[external_userid] = (customer_info, current_time)
while len(self._customer_cache) > self._CUSTOMER_CACHE_MAX:
self._customer_cache.pop(next(iter(self._customer_cache)), None)
return customer_info
return None
def clear(self) -> None:
self._customer_cache.clear()