Files
LangBot/src/langbot/pkg/api/http/controller/groups/system.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

315 lines
14 KiB
Python

import json
import quart
import sqlalchemy
from .. import group
from .....utils import constants
from .....entity.persistence.metadata import WorkspaceMetadata
from ...authz import Permission
from ...context import RequestContext
from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled
from .....workspace.invitation_delivery import InvitationDeliveryService
@group.group_class('system', '/api/v1/system')
class SystemRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
# Read wizard_status and wizard_progress from metadata table
wizard_status = 'none'
wizard_progress = None
try:
authorization = quart.request.headers.get('Authorization', '')
if authorization.startswith('Bearer '):
account, _ = await self._authenticate_account(authorization.removeprefix('Bearer '))
request_context = await self._resolve_account_context(account, group.AuthType.USER_TOKEN)
if request_context is not None:
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
async def load_workspace_metadata():
return await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(
WorkspaceMetadata.key,
WorkspaceMetadata.value,
).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key.in_(['wizard_status', 'wizard_progress']),
)
)
cloud_runtime = (
getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
)
if cloud_runtime:
if not callable(tenant_uow):
raise RuntimeError('Cloud system metadata requires an explicit tenant UoW')
async with tenant_uow(request_context.workspace_uuid):
result = await load_workspace_metadata()
else:
result = await load_workspace_metadata()
# ``execute_async`` deliberately preserves its historical
# AsyncConnection result shape. Selecting the two fields
# explicitly keeps this reader independent of ORM Session
# scalar semantics inside a tenant UoW.
for row in result:
if row.key == 'wizard_status':
wizard_status = row.value
elif row.key == 'wizard_progress':
try:
wizard_progress = json.loads(row.value)
except (json.JSONDecodeError, TypeError):
wizard_progress = None
except Exception:
pass
# ``system.outbound_ips`` may be a comma-separated string instead of
# a list when injected via the SYSTEM__OUTBOUND_IPS env var into a
# pre-existing data/config.yaml that lacks the key (env overrides
# only coerce to list when the key already holds one).
outbound_ips = self.ap.instance_config.data.get('system', {}).get('outbound_ips', [])
if isinstance(outbound_ips, str):
outbound_ips = [ip.strip() for ip in outbound_ips.split(',') if ip.strip()]
elif isinstance(outbound_ips, list):
outbound_ips = [str(ip).strip() for ip in outbound_ips if str(ip).strip()]
else:
outbound_ips = []
invitation_delivery_service = getattr(self.ap, 'invitation_delivery_service', None)
if invitation_delivery_service is None:
invitation_delivery_service = InvitationDeliveryService(self.ap)
return self.success(
data={
'version': constants.semantic_version,
'debug': constants.debug_mode,
'edition': constants.edition,
'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get(
'enable_marketplace', True
),
'cloud_service_url': (
self.ap.instance_config.data.get('space', {}).get('url', 'https://space.langbot.app')
),
'allow_modify_login_info': self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
),
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
'disable_models_service', False
),
# Exposed independently of Box status so the WebUI cannot
# infer stdio permission from sandbox availability.
'mcp_stdio_enabled': stdio_mcp_enabled(self.ap),
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
'outbound_ips': outbound_ips,
'invitation_delivery': invitation_delivery_service.capability(),
'wizard_status': wizard_status,
'wizard_progress': wizard_progress,
}
)
@self.route(
'/wizard/completed',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.WORKSPACE_UPDATE,
)
async def _(request_context: RequestContext) -> str:
"""Mark wizard status in metadata table and clear progress.
Accepts JSON body: { "status": "skipped" | "completed" }
"""
data = await quart.request.get_json(silent=True) or {}
status = data.get('status', 'completed')
if status not in ('skipped', 'completed'):
return self.http_status(400, 400, f'Invalid wizard status: {status}')
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(WorkspaceMetadata)
.where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_status',
)
.values(value=status)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(WorkspaceMetadata).values(
workspace_uuid=request_context.workspace_uuid,
key='wizard_status',
value=status,
)
)
# Clear wizard progress when wizard is completed/skipped
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
except Exception:
raise
return self.success(data={})
@self.route(
'/wizard/progress',
methods=['PUT'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.WORKSPACE_UPDATE,
)
async def _(request_context: RequestContext) -> str:
"""Save wizard progress to metadata table.
Accepts JSON body with wizard state fields:
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
"bot_saved": bool, "selected_runner": str|null }
"""
data = await quart.request.get_json(silent=True) or {}
progress_json = json.dumps(data, ensure_ascii=False)
try:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(WorkspaceMetadata).where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
)
if result.first():
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(WorkspaceMetadata)
.where(
WorkspaceMetadata.workspace_uuid == request_context.workspace_uuid,
WorkspaceMetadata.key == 'wizard_progress',
)
.values(value=progress_json)
)
else:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(WorkspaceMetadata).values(
workspace_uuid=request_context.workspace_uuid,
key='wizard_progress',
value=progress_json,
)
)
except Exception:
raise
return self.success(data={})
@self.route(
'/tasks',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
task_type = quart.request.args.get('type')
task_kind = quart.request.args.get('kind')
if task_type == '':
task_type = None
if task_kind == '':
task_kind = None
return self.success(
data=self.ap.task_mgr.get_tasks_dict(
task_type,
task_kind,
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
)
)
@self.route(
'/tasks/<task_id>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(task_id: str, request_context: RequestContext) -> str:
task = self.ap.task_mgr.get_task_by_id(
int(task_id),
instance_uuid=request_context.instance_uuid,
workspace_uuid=request_context.workspace_uuid,
placement_generation=request_context.placement_generation,
)
if task is None:
return self.http_status(404, 404, 'Task not found')
return self.success(data=task.to_dict())
@self.route(
'/storage-analysis',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.AUDIT_VIEW,
)
async def _(request_context: RequestContext) -> str:
return self.success(data=await self.ap.maintenance_service.get_storage_analysis(request_context))
@self.route(
'/debug/plugin/action',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RUNTIME_OPERATE,
)
async def _(request_context: RequestContext) -> str:
if not constants.debug_mode:
return self.http_status(403, 403, 'Forbidden')
await self.ap.plugin_connector.require_workspace_context(request_context)
data = await quart.request.json
class AnoymousAction:
value = 'anonymous_action'
def __init__(self, value: str):
self.value = value
resp = await self.ap.plugin_connector.handler.call_action(
AnoymousAction(data['action']),
data['data'],
timeout=data.get('timeout', 10),
action_context=self.ap.plugin_connector.handler.require_bound_action_context().without_installation(),
)
return self.success(data=resp)
@self.route(
'/status/plugin-system',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
await self.ap.plugin_connector.require_workspace_context(request_context)
plugin_connector_error = 'ok'
is_connected = True
try:
await self.ap.plugin_connector.ping_plugin_runtime()
except Exception as e:
plugin_connector_error = str(e)
is_connected = False
return self.success(
data={
'is_enable': self.ap.plugin_connector.is_enable_plugin,
'is_connected': is_connected,
'plugin_connector_error': plugin_connector_error,
}
)