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
+218 -4
View File
@@ -2,12 +2,42 @@ api:
port: 5300
webhook_prefix: 'http://127.0.0.1:5300'
extra_webhook_prefix: ''
# Canonical browser origin when WebUI and API use different origins in
# development (for example http://localhost:3000). Production bundled UI
# may leave this empty when webhook_prefix already has the browser origin.
# OAuth redirects trust only these server-side values, never request Host
# or Origin headers.
webui_url: ''
# Global API key for the HTTP service API and the MCP server. When set to a
# non-empty string, this key is accepted anywhere a web-UI-created API key is
# accepted (X-API-Key header or "Authorization: Bearer <key>"), WITHOUT any
# login session and without a database record. Leave empty to disable.
# Keep this value secret; only enable it on trusted/internal deployments.
global_api_key: ''
workspace:
invitations:
# Public WebUI origin used to build invitation links. Leave empty to
# use api.webui_url, then api.webhook_prefix. Set via
# WORKSPACE__INVITATIONS__PUBLIC_WEB_URL in container deployments.
public_web_url: ''
email:
# Optional invitation email delivery. Empty provider keeps
# invitations link-only. Supported: resend, smtp.
provider: ''
from: ''
timeout_seconds: 10
resend:
api_url: 'https://api.resend.com/emails'
# Secret. Set via WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY.
api_key: ''
smtp:
host: ''
port: 587
username: ''
# Secret. Set via WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD.
password: ''
starttls: true
ssl: false
command:
enable: true
prefix:
@@ -17,6 +47,35 @@ command:
concurrency:
pipeline: 20
session: 1
# Hard admission limits for queued + running pipeline queries.
pending_queries: 1000
pending_queries_per_workspace: 100
webhooks:
# Bound database materialization and per-message outbound fan-out.
# Existing rows above this limit remain deletable through the management
# API, but only this many enabled destinations are dispatched.
# Supports WEBHOOKS__MAX_PER_WORKSPACE (hard cap: 64).
max_per_workspace: 16
# Instance-wide request admission. Delivery fails open when every slot is
# occupied instead of retaining an unbounded queue of webhook tasks.
# Supports WEBHOOKS__MAX_INFLIGHT_REQUESTS (hard cap: 128).
max_inflight_requests: 16
cloud:
# Operational safety ceilings for the one logical Cloud instance. These
# are not subscription entitlements. An authoritative directory update
# that would exceed them is rejected atomically rather than truncated.
directory:
# Tune downward from the measured production capacity curve. Core has
# an absolute safety ceiling of 5,000 active Workspaces.
max_active_workspaces: 1000
# Full snapshots contain current Workspaces only. Archived tombstones
# are delivered through bounded per-Workspace deltas.
max_snapshot_workspaces: 1000
# Aggregate memberships accepted in one signed snapshot or delta.
max_snapshot_memberships: 20000
# Signed control-plane envelope buffered by the closed adapter before
# JSON/JWS verification (32 MiB; absolute maximum 64 MiB).
max_response_bytes: 33554432
proxy:
http: ''
https: ''
@@ -26,6 +85,15 @@ system:
recovery_key: ''
allow_modify_login_info: true
disabled_adapters: []
blocking_executor:
# All asyncio.to_thread work shares this process-wide bounded pool.
# Both running threads and queued calls are capped to prevent tenant
# bursts from creating an unbounded queue of retained request objects.
max_workers: 8
max_pending: 128
# One trusted Workspace can occupy at most this many running + queued
# slots. This must not exceed half of max_workers.
max_inflight_per_scope: 4
# Public outbound IP addresses of this LangBot deployment. Some platforms
# (e.g. WeCom, WeChat Official Account, QQ Official API) require the
# caller's IPs to be added to their trusted-IP / IP-whitelist settings.
@@ -37,6 +105,7 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
max_knowledge_bases: -1
# When set to a non-empty string, every pipeline is forced to use this
# Box sandbox-scope template regardless of its own configuration, and
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
@@ -46,6 +115,32 @@ system:
task_retention:
# Keep at most this many completed async task records in memory
completed_limit: 200
# Bound progress output retained by one task, including running tasks.
max_log_chars: 200000
# Protect the shared process from user-triggered operation storms.
max_active_user_tasks: 256
max_active_user_tasks_per_workspace: 8
session_retention:
# Process-local conversation sessions are a cache, not durable history.
max_entries: 2000
max_entries_per_workspace: 200
idle_ttl_seconds: 86400
max_conversations_per_session: 20
max_messages_per_conversation: 100
websocket_retention:
# Bound live browser sockets and per-Workspace fan-out in the shared process.
max_connections: 1024
max_connections_per_workspace: 32
# Idle proxy runtimes are evicted when this process-local cache fills.
max_workspace_proxies: 1024
max_conversations_per_workspace: 200
max_messages_per_conversation: 100
conversation_idle_ttl_seconds: 86400
send_queue_size: 100
response_limits:
# Defense in depth for tenant-configured upstream providers.
max_generated_chars: 1048576
max_stream_chunks: 100000
jwt:
expire: 604800
secret: ''
@@ -54,13 +149,33 @@ database:
sqlite:
path: 'data/langbot.db'
postgresql:
# Optional SQLAlchemy URL (postgresql[+asyncpg]://...). When set, it
# overrides the structured fields and preserves TLS/query options.
url: ''
host: '127.0.0.1'
port: 5432
user: 'postgres'
password: 'postgres'
database: 'postgres'
# One bounded pool is shared by business data and Cloud pgvector.
pool_size: 10
max_overflow: 10
pool_timeout_seconds: 30
pool_recycle_seconds: 1800
# Applied only to Cloud runtime connections. The one-shot release
# migration uses its operator connection without these short limits.
statement_timeout_ms: 60000
lock_timeout_ms: 5000
idle_in_transaction_session_timeout_ms: 60000
cloud_migration:
# `langbot migrate --cloud` reads an operator-only PostgreSQL DSN from
# this environment variable. The operator role must differ from the
# runtime role above; never put its password in this file or CLI args.
operator_dsn_env: 'LANGBOT_CLOUD_MIGRATION_DSN'
vdb:
use: chroma
# Bound process-local collection/index handles across all Workspaces.
runtime_cache_limit: 1024
qdrant:
url: ''
host: localhost
@@ -82,6 +197,11 @@ vdb:
token: ''
db_name: ''
pgvector:
# SaaS/shared-schema deployments reuse database.postgresql. OSS can
# keep this false when deliberately using an external pgvector DB.
use_business_database: false
# Release migrations create one partial ANN index per enabled value.
allowed_dimensions: [384, 512, 768, 1024, 1536]
host: '127.0.0.1'
port: 5433
database: 'langbot'
@@ -99,6 +219,9 @@ vdb:
request_timeout: 5000 # per-request timeout in ms (glide default 250ms is too low for KNN)
storage:
use: local
# Bound every object materialized into Core memory. Built-in Local/S3
# providers enforce this while reading (hard cap: 64 MiB).
max_object_read_bytes: 10485760
cleanup:
# Enable periodic cleanup of local/S3 uploaded files and old log files
enabled: true
@@ -108,21 +231,79 @@ storage:
uploaded_file_retention_days: 7
# LangBot log files older than this many days will be deleted
log_retention_days: 3
# Bound per-Workspace file cleanup and diagnostic candidate lists.
# Supports STORAGE__CLEANUP__MAX_FILES_PER_RUN (hard cap: 10000).
max_files_per_run: 1000
s3:
endpoint_url: ''
access_key_id: ''
secret_access_key: ''
region: 'us-east-1'
bucket: 'langbot-storage'
# boto3 is synchronous; bound the number of operations delegated to
# worker threads so an S3 slowdown cannot saturate the process.
max_concurrency: 16
plugin:
enable: true
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
worker:
# Instance-wide maximum for every plugin installation. Plugin
# manifests cannot raise or override these limits.
max_cpus: 1.0
max_memory_mb: 512
max_pids: 128
max_open_files: 256
max_file_size_mb: 512
# Instance-wide admission budgets. The effective worker count is the
# lowest of max_workers, max_total_cpus/max_cpus and
# max_total_memory_mb/max_memory_mb.
max_workers: 16
max_total_cpus: 8.0
max_total_memory_mb: 8192
# Includes disabled and historical installation fences retained to
# reject stale desired-state replay.
max_installations: 10000
# Restart storms are globally serialized by default. Repeated
# unexpected exits within the configured window open a Runtime-wide
# circuit; one half-open probe must remain stable before other
# installations may restart.
max_concurrent_restarts: 1
restart_failure_threshold: 8
restart_failure_window_seconds: 30.0
restart_circuit_open_seconds: 60.0
# Cloud shared Runtime sets this to true and fails closed unless
# delegated cgroup v2 controllers are available.
require_hard_limits: false
binary_storage:
# Max bytes for a single plugin binary storage value
max_value_bytes: 10485760
mcp:
# Bound instance-wide MCP startup and shutdown bursts. Supports
# MCP__LIFECYCLE_CONCURRENCY and is clamped to a maximum of 128.
lifecycle_concurrency: 16
stdio:
# Independent gate for local stdio MCP transports. Cloud v2 sets
# MCP__STDIO__ENABLED=false even when Box Runtime is available.
enabled: true
monitoring:
query_limits:
# Maximum records materialized by one paginated monitoring request.
# Supports MONITORING__QUERY_LIMITS__PAGE_ROWS (hard cap: 5000).
page_rows: 1000
# CSV exports are currently assembled in memory. Keep this lower than
# the historical 100000-row default (hard cap: 50000).
export_rows: 10000
# Maximum related records returned by one session/message detail view
# (hard cap: 10000). Aggregate statistics remain database-computed.
detail_rows: 2000
# Token charts are grouped in SQL and return only the newest buckets
# (hard cap: 10000). Supports an environment variable override.
timeseries_buckets: 1000
# Bound high-offset scans that can otherwise monopolize PostgreSQL CPU
# (hard cap: 10000000).
max_offset: 1000000
auto_cleanup:
# Enable automatic cleanup of expired monitoring records
enabled: true
@@ -132,6 +313,9 @@ monitoring:
check_interval_hours: 1
# Number of expired rows to delete per table batch
delete_batch_size: 1000
# Prevent one large Workspace backlog from monopolizing PostgreSQL.
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
max_batches_per_table_per_run: 4
box:
# Master switch for the Box sandbox runtime. When false, LangBot does NOT
# attempt to connect to a remote Box runtime nor start a local stdio Box
@@ -142,12 +326,42 @@ box:
enabled: true
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
# both LangBot and Box. Keep the shared secret out of this config file.
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
limits:
max_sessions: 64 # Includes persistent sessions. New sessions fail explicitly when this cap is reached.
max_managed_processes: 64 # Maximum concurrently running stdio MCP / managed processes.
max_completed_processes: 256 # Global cap for retained exited-process diagnostics.
completed_process_retention_sec: 300 # Keep exited-process diagnostics before releasing memory.
max_sessions: 64
max_managed_processes: 64
max_completed_processes: 256
# Core scans a Workspace before and after quota-enforced executions.
# Fail closed instead of repeatedly walking an inode bomb.
# Supports BOX__LIMITS__MAX_WORKSPACE_ENTRIES (hard cap: 1000000).
max_workspace_entries: 100000
# Retained admission fences prevent replay after entitlement expiry or
# revocation. Fail closed before that monotonic state can grow without
# bound; Cloud may override this with BOX__LIMITS__MAX_ADMISSION_RECORDS.
max_admission_records: 100000
max_rpc_file_bytes: 20971520
# Cloud v2 overrides these values through the instance config/environment.
# OSS keeps admission disabled and preserves the existing multi-session
# local behavior. These limits are Runtime-owned and cannot be relaxed by
# a pipeline, Workspace entitlement, or tool call.
admission:
required: false
logical_session_id: 'global'
required_backend: 'nsjail'
max_sessions: 1
max_managed_processes: 0
max_grant_ttl_sec: 300
max_timeout_sec: 120
cpus: 1.0
memory_mb: 512
pids_limit: 128
read_only_rootfs: true
# OSS admission-disabled mode uses 0 for unlimited compatibility.
# Cloud bootstrap requires a positive hard quota.
workspace_quota_mb: 0
readiness_cache_sec: 15
local:
profile: 'default'
image: '' # Custom local sandbox image. Leave empty to use the profile default.
+8 -1
View File
@@ -356,6 +356,7 @@
isConnected: false,
ws: null,
connectionId: null,
sessionToken: null,
sessionId: getOrCreateSessionId(),
reconnectAttempts: 0,
heartbeatTimer: null,
@@ -538,7 +539,12 @@
state.ws.onopen = function () {
state.reconnectAttempts = 0;
startHeartbeat();
state.ws.send(
JSON.stringify({
type: "authenticate",
token: state.sessionToken || "",
}),
);
};
state.ws.onmessage = function (event) {
@@ -576,6 +582,7 @@
state.connectionId = data.connection_id;
if (state.hasConnected) loadHistory(true);
state.hasConnected = true;
startHeartbeat();
updateStatusDot();
updateSendBtn();
break;