25 KiB
LangBot 多租户与多用户改造方案
目标
本方案面向 LangBot 从“单实例单管理员”演进到 SaaS 友好的“多 workspace、多账户、多权限”架构。
核心定义:
- Account:登录主体。一个自然人或服务账号,可加入多个 workspace。
- Workspace:租户边界。一个 workspace 内可拥有多个用户、机器人、流水线、模型、知识库、扩展、监控数据与 API Key。
- Membership:账户与 workspace 的关系,承载角色与权限。
- Role/Permission:workspace 内权限,不再用“是否是当前唯一用户”来决定访问能力。
目标体验:
- 新用户登录后可以创建 workspace、加入 workspace、切换 workspace。
- 同一个账户可加入多个 workspace,每个 workspace 权限不同。
- 一个 workspace 可邀请多个用户协作,并分别设置 owner/admin/editor/viewer 等权限。
- 所有业务资源默认属于某个 workspace,所有 API 默认在当前 workspace 下工作。
- Plugin SDK、MCP、知识库、模型调用、监控日志都能拿到稳定的 workspace 上下文,并且不跨租户泄露数据。
调研结论
当前 LangBot 的单用户假设
LangBot 现在已经有 users 表和 JWT 登录,但仍是单用户/单租户模型:
src/langbot/pkg/entity/persistence/user.py的User只保存user/password/account_type/space_*,没有角色、状态、workspace 关系。src/langbot/pkg/api/http/service/user.py通过is_initialized()判断系统是否已有用户;create_or_update_space_user()在系统已初始化且邮箱不匹配时拒绝新用户,这直接限制了多用户登录。src/langbot/pkg/api/http/controller/group.py的AuthType.USER_TOKEN验证后只向 handler 注入user_email;JWT payload 也只有user,没有account_id、workspace_id、role、permissions。src/langbot/pkg/api/http/service/apikey.py的 API Key 只验证 key 是否存在,没有 owner、scope、workspace。web/src/app/infra/http/BaseHttpClient.ts从localStorage.token读取单个 token,并在所有请求上加Authorization;前端没有 workspace selector,也没有当前 workspace 上下文。
当前登录流程更像“初始化一个本地管理账号”,而不是 SaaS 账户体系。要支持多用户,必须把“初始化状态”和“首个 workspace 创建”拆开。
业务资源当前都是全局资源
主要持久化表没有租户字段:
- Bot:
bots - Pipeline:
legacy_pipelines、pipeline_run_records - Model:
model_providers、llm_models、embedding_models、rerank_models - Plugin:
plugin_settings - MCP:
mcp_servers - RAG:
knowledge_bases、knowledge_base_files、knowledge_base_chunks - Monitoring:
monitoring_messages、monitoring_llm_calls、monitoring_sessions、monitoring_errors、monitoring_embedding_calls、monitoring_feedback - API Key:
api_keys - Webhook:
webhooks - Metadata:
metadata - Binary storage:
binary_storages
对应服务也直接 select 全表,例如:
BotService.get_bots()返回所有 bot。PipelineService.get_pipelines()返回所有 pipeline。ModelProviderService.get_providers()返回所有 provider。MCPService.get_mcp_servers()返回所有 MCP server。- 插件和二进制存储没有 workspace 维度,插件 workspace storage 在 SDK 里还硬编码为
default。
所以改造重点不是只给用户表加字段,而是给资源访问层统一加入 workspace scope。
运行时也存在全局单例假设
src/langbot/pkg/core/stages/build_app.py 初始化的是一个全局 Application,其中包含单例:
platform_mgrpipeline_mgrmodel_mgrtool_mgrplugin_connectorsess_mgrrag_mgrvector_db_mgr
当前运行时把所有 bot、pipeline、model、plugin、MCP 都加载到同一套内存管理器。多租户改造需要决定:是共享运行时并在对象上带 workspace 过滤,还是每个 workspace 拆 runtime shard。第一阶段建议共享进程、强制 workspace-aware;等规模变大后再演进为按 workspace 分片。
Plugin SDK 对 workspace 的假设
SDK 当前只认识 bot/pipeline/query/session,不认识租户:
src/langbot_plugin/api/entities/builtin/pipeline/query.py的Query有query_id/launcher_type/launcher_id/sender_id/bot_uuid/pipeline_uuid,没有workspace_id/account_id。src/langbot_plugin/api/entities/builtin/provider/session.py的Session只按launcher_type + launcher_id表达会话。src/langbot_plugin/api/proxies/langbot_api.py暴露get_bots/get_llm_models/invoke_llm/list_tools/vector_*等 Host API,都是全局语义。src/langbot_plugin/runtime/io/handlers/plugin.py的set_workspace_storage/get_workspace_storage把owner_type设为workspace,但owner固定为default。- LangBot 侧
src/langbot/pkg/plugin/handler.py处理插件请求时,会把GET_BOTS、GET_LLM_MODELS、VECTOR_*等转到全局服务。
这意味着多租户落地时,不能只在 Web API 层过滤;插件可以通过 Host API 访问全局资源,所以 SDK/Runtime 通信也必须传递 workspace context。
推荐总体架构
采用“单实例多 workspace,资源行级隔离,运行时上下文隔离”的架构:
flowchart TD
A["Account"] --> B["WorkspaceMembership"]
B --> C["Workspace"]
C --> D["Bots"]
C --> E["Pipelines"]
C --> F["Models & Providers"]
C --> G["Knowledge Bases"]
C --> H["Extensions: Plugins / MCP"]
C --> I["API Keys & Webhooks"]
C --> J["Monitoring"]
D --> K["Runtime Query"]
E --> K
K --> L["Plugin Runtime"]
K --> M["MCP Runtime"]
L --> N["Workspace-scoped Host APIs"]
原则:
- 账户全局唯一,workspace 是所有业务资源的归属边界。
- 所有 HTTP handler 在进入业务服务前解析出
RequestContext(account, workspace, membership, permissions)。 - 所有 service 方法显式接收
ctx或workspace_id,禁止在业务服务里无条件 select 全表。 - 运行时对象的 key 从
uuid扩展为(workspace_id, uuid)或使用全局唯一 uuid 但必须记录 workspace_id 并校验。 - 插件/MCP/知识库/模型调用都按 query 所属 workspace 过滤可用资源。
数据模型设计
Account
替代现有 users 的语义,建议保留表名但升级字段,避免过大迁移:
字段建议:
iduuidemailpassword_hashdisplay_nameavatar_urlaccount_type:local | spacestatus:active | disabled | deletedspace_account_uuidspace_access_tokenspace_refresh_tokenspace_access_token_expires_atspace_api_keycreated_atupdated_at
兼容策略:
- 旧字段
user迁移为email,可以短期保留 alias。 - 旧
password迁移为password_hash,也可先保持列名不变,service 层改命名。 - JWT 中不要继续只放 email,应放
sub=account_uuid。
Workspace
新增 workspaces:
uuidnameslugavatar_urltype:personal | teamstatus:active | suspended | deleteddefault_languagecreated_by_account_uuidcreated_atupdated_at
每个账户首次登录时自动创建一个 personal workspace。旧单用户实例迁移时创建一个 Default Workspace。
WorkspaceMembership
新增 workspace_memberships:
workspace_uuidaccount_uuidrole:owner | admin | developer | operator | viewerstatus:active | invited | disabledinvited_by_account_uuidjoined_atcreated_atupdated_at
唯一索引:
(workspace_uuid, account_uuid)
WorkspaceInvitation
新增 workspace_invitations:
uuidworkspace_uuidemailroletoken_hashexpires_ataccepted_atcreated_by_account_uuidcreated_at
用于邀请外部用户加入 workspace。Space OAuth 登录时可以根据 email 自动匹配未接受邀请。
Role 与 Permission
先用固定角色,后续再做自定义角色。
建议权限:
workspace.managemember.viewmember.invitemember.update_rolemember.removebot.viewbot.managepipeline.viewpipeline.managemodel.viewmodel.manageknowledge.viewknowledge.manageextension.viewextension.managemonitoring.viewapikey.managewebhook.managebilling.viewbilling.manage
角色映射:
| Role | 说明 | 权限 |
|---|---|---|
| owner | workspace 拥有者 | 全部权限;可转让 owner;不可被其他角色移除 |
| admin | 管理员 | 除 owner 转让和删除 workspace 外的全部权限 |
| developer | 构建者 | 管理 bot、pipeline、model、knowledge、extension、webhook,可看监控 |
| operator | 运营者 | 查看和启停 bot、查看监控、查看配置,不可改密钥和删除资源 |
| viewer | 只读成员 | 只读资源和监控 |
业务资源加 workspace_uuid
以下表需要新增 workspace_uuid:
botslegacy_pipelinespipeline_run_recordsmodel_providersllm_modelsembedding_modelsrerank_modelsplugin_settingsmcp_serversknowledge_basesknowledge_base_filesknowledge_base_chunksmonitoring_*api_keyswebhooksbinary_storagesmetadata
索引建议:
- 所有资源表加
(workspace_uuid, created_at)或(workspace_uuid, updated_at)。 - 资源唯一键从单列改为 workspace 复合唯一:
bots.uuid可保持全局唯一,但查询仍必须带 workspace。plugin_settings主键从(plugin_author, plugin_name)改为(workspace_uuid, plugin_author, plugin_name)。mcp_servers.name如果未来要求唯一,必须是(workspace_uuid, name)。metadata.key改为(workspace_uuid, key),系统级 metadata 单独放system_metadata或使用workspace_uuid=NULL。binary_storages.unique_key建议改为workspace_uuid + owner_type + owner + key的 hash。
API Key
API Key 必须归属于 workspace:
workspace_uuidcreated_by_account_uuidscopesexpires_atlast_used_atstatus
验证 API Key 后生成 RequestContext:
account_uuid=None或 service account uuidworkspace_uuid=key.workspace_uuidpermissions=key.scopes
这样 /api/v1/platform/bots/<uuid>/send_message 之类接口不会跨 workspace 操作 bot。
后端改造方案
RequestContext
新增统一上下文对象,例如:
class RequestContext:
account_uuid: str | None
workspace_uuid: str
role: str | None
permissions: set[str]
auth_type: Literal["user_token", "api_key"]
改造 RouterGroup.route():
USER_TOKEN:验证 JWT,读取account_uuid,再从 header/query/cookie 中解析 current workspace。API_KEY:验证 API Key,直接得到 workspace。USER_TOKEN_OR_API_KEY:两者都返回同一种RequestContext。- handler 参数从可选
user_email升级为可选ctx;兼容期同时支持user_email。
当前 workspace 传递方式:
- 推荐 header:
X-Workspace-Id: <workspace_uuid> - Web 前端同时把当前 workspace 存在 localStorage。
- 如果未传,后端用账户最近使用 workspace 或第一个 active membership。
JWT payload:
{
"sub": "account_uuid",
"email": "user@example.com",
"iss": "LangBot-...",
"exp": 1234567890
}
不要把 workspace 写死在 JWT 里,否则切换 workspace 需要刷新 token。可以额外支持短 TTL workspace token,但第一阶段不必。
服务层改造模式
所有 service 方法增加 ctx 或 workspace_uuid:
async def get_bots(self, ctx: RequestContext, include_secret: bool = True):
require(ctx, "bot.view")
query = sqlalchemy.select(Bot).where(Bot.workspace_uuid == ctx.workspace_uuid)
需要改造的服务:
UserService:拆成 AccountService + WorkspaceService 更清晰。ApiKeyService:按 workspace 管理 key。BotService:所有 bot 查询/创建/更新/删除按 workspace。PipelineService:所有 pipeline 查询/默认 pipeline 按 workspace。ModelProviderService和 model services:按 workspace 隔离 provider 和 model。MCPService:按 workspace 管理 MCP server,运行时按 workspace host。KnowledgeService/RAGRuntimeService:按 workspace 管理 KB、文件、collection。MonitoringService:记录和查询都带 workspace。WebhookService:按 workspace 管理 webhook。PluginRuntimeConnector:插件安装、设置、配置按 workspace。
HTTP API 形态
保留现有路径,靠 X-Workspace-Id 表示当前 workspace,可减少前端和 SDK 破坏:
GET /api/v1/workspacesPOST /api/v1/workspacesGET /api/v1/workspaces/currentPUT /api/v1/workspaces/currentGET /api/v1/workspaces/<workspace_uuid>/membersPOST /api/v1/workspaces/<workspace_uuid>/invitationsPUT /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>DELETE /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>
现有资源 API:
/api/v1/platform/bots/api/v1/pipelines/api/v1/provider/*/api/v1/plugins/api/v1/mcp/api/v1/knowledge
继续保留,但必须从 RequestContext.workspace_uuid 过滤。
对外 API Key 也使用相同路径,只是由 key 决定 workspace。
初始化流程
现有 /api/v1/user/init 含义改为“创建首个账号和首个 workspace”:
- 如果系统没有任何 account:
- 创建 account。
- 创建 personal/team workspace。
- 创建 owner membership。
- 创建默认 pipeline。
- 标记 wizard status 到该 workspace metadata。
- 如果系统已有 account:
- 禁止无邀请注册,除非配置允许公开注册。
- Space OAuth 登录后,如果没有 membership,引导创建 workspace 或接受邀请。
/api/v1/user/account-info 不应再只返回 first user,应返回:
initializedregistration_modespace_enableddefault_login_methods
登录成功后前端调用 /api/v1/workspaces 选择 workspace。
运行时隔离
第一阶段采用共享进程 + workspace-aware runtime:
RuntimeBot增加workspace_uuid。RuntimePipeline增加workspace_uuid。Query增加workspace_uuid,从 bot/pipeline 派生。SessionManager.get_session()key 从(launcher_type, launcher_id)改为(workspace_uuid, bot_uuid, launcher_type, launcher_id)。PipelineManager.pipeline_dictkey 可保持 pipeline uuid,但所有 load/get 都校验 workspace;如果 uuid 不是全局唯一则改为(workspace_uuid, pipeline_uuid)。ModelManagerprovider/model 加 workspace 过滤;get_model_by_uuid必须确保 query workspace 可访问。ToolManager中 MCP tools、plugin tools 按 query workspace 过滤。
后续规模化时可演进:
- workspace runtime shard:每个 workspace 一套 plugin runtime/MCP runtime。
- 大客户独立进程或独立数据库。
Plugin SDK 与 Runtime 改造
Query/Event 增加 workspace context
SDK Query 增加:
workspace_uuid: strworkspace_slug: str | Noneaccount_uuid: str | None,仅 Web/API 触发时可能有,聊天平台消息通常为空。
Event 模型通过 event.query.workspace_uuid 可拿到租户上下文;序列化时也应包含这些字段。
向后兼容:
- 字段可选,默认
None。 - 老插件不感知这些字段也能跑。
- 新插件可通过
ctx.event.query.workspace_uuid或新增ctx.get_workspace()访问。
Host API 默认按当前 workspace 限制
LangBotAPIProxy 的以下方法必须由 Host 端按 workspace 过滤:
get_botsget_bot_infosend_messageget_llm_modelsinvoke_llmlist_plugins_manifestlist_commandslist_toolscall_toolinvoke_embeddingvector_*list_knowledge_basesretrieve_knowledge
建议新增显式方法:
get_workspace_info()get_current_account()get_workspace_storage(...)
但不要让插件传入任意 workspace id 来越权访问。插件请求的 workspace 应由 Runtime 根据当前 query/plugin connection 填充。
Workspace storage 修复
当前 SDK runtime 中:
data["owner_type"] = "workspace"
data["owner"] = "default"
必须改为:
- 如果请求来自 query/event:owner 为
workspace_uuid。 - 如果请求来自后台插件任务:owner 为 plugin 安装所属 workspace。
- Host 侧
binary_storages加workspace_uuid,并在 unique key 中包含 workspace。
Plugin storage 建议也同时加 workspace:
- 现在 plugin storage owner 是
author/name,这会导致同一插件在不同 workspace 的私有数据冲突。 - 应改为
(workspace_uuid, plugin_id, key)。
插件安装与配置
plugin_settings 从全局变为 workspace-scoped:
- 同一个插件可安装到多个 workspace。
- 每个 workspace 有自己的 enabled、priority、config、install_source、install_info。
- 插件 runtime 列表需要能按 workspace 过滤。
实现路线有两种:
- 共享插件进程,插件代码只加载一份,设置和调用时附带 workspace。
- 每个 workspace 一个插件容器实例,隔离最彻底但资源占用更高。
推荐第一阶段采用方案 1,但要求:
- 所有 RuntimeToLangBot/PluginToRuntime action 都能携带
workspace_uuid。 - 插件 config 获取时按 workspace 返回。
- 插件 page API 请求必须校验当前用户在该 workspace 有访问权限。
MCP
MCP server 是租户资源:
mcp_servers.workspace_uuid。- MCP session key 从
server_name改为(workspace_uuid, server_name)或使用全局 uuid。 - Pipeline extension preferences 中绑定 MCP server uuid 时,只能绑定同 workspace 的 server。
- MCP tool 列表在 query 执行时按 query.workspace_uuid + pipeline 绑定关系过滤。
前端改造
Workspace selector
Home layout 顶部或 sidebar 增加 workspace selector:
- 当前 workspace 名称和头像。
- 切换 workspace 后写入
localStorage.currentWorkspaceId。 - 所有请求自动带
X-Workspace-Id。 - 切换后刷新 sidebar 数据和页面缓存。
BaseHttpClient request interceptor 增加:
const workspaceId = localStorage.getItem("currentWorkspaceId");
if (workspaceId) config.headers["X-Workspace-Id"] = workspaceId;
用户与成员管理页面
新增页面:
/home/workspace/settings/home/workspace/members/home/workspace/invitations
能力:
- owner/admin 邀请成员。
- owner/admin 修改成员角色。
- owner 移除成员、转让 owner。
- 所有人可切换 workspace。
- viewer/operator 在 UI 上隐藏不可操作按钮,但后端仍做权限校验。
登录与注册
登录后流程:
authUser拿 token。initializeUserInfo()获取 account info。GET /api/v1/workspaces。- 如果没有 workspace:进入创建 workspace 向导。
- 如果有多个 workspace:默认进入最近使用 workspace,可切换。
注册页不再表达“初始化管理员账号”,而是:
- 首次系统启动:创建首个 owner + default workspace。
- 后续:根据配置允许公开注册,或只能接受邀请。
旧页面影响
需要逐个检查这些页面的数据加载是否都依赖当前 workspace:
- Bots
- Pipelines
- Plugins/Market/MCP
- Knowledge
- Monitoring
- Models dialog
- API integration dialog
- Wizard
迁移方案
迁移阶段 0:准备
- 引入
workspaces、workspace_memberships、workspace_invitations。 - 给
users增加uuid/status/display_name等字段。 - 创建
RequestContext,但先不强制所有服务改完。
迁移阶段 1:默认 workspace
对现有实例执行迁移:
- 创建
Default Workspace。 - 找到现有第一个 user,设为 owner。
- 所有已有资源写入
workspace_uuid=default_workspace_uuid。 metadata迁入 default workspace;确实全局的配置放到system_metadata。binary_storages中owner_type=workspace, owner=default改为 owner 为 default workspace uuid。- 插件
plugin_settings归入 default workspace。
迁移阶段 2:服务层强制 scope
- 改所有 service 查询,必须要求
workspace_uuid。 - API Key 迁移为 workspace key。
- 所有写操作必须检查权限。
- 监控和任务查询按 workspace 过滤。
迁移阶段 3:运行时上下文
Query、Session、RuntimeBot、RuntimePipeline增加 workspace。- Plugin/MCP/Model/RAG runtime 全部按 workspace 过滤。
- 修复 SDK workspace storage。
迁移阶段 4:前端多 workspace
- 登录后 workspace 选择。
- Header/sidebar workspace switcher。
- 成员和邀请管理。
- 所有 API 请求带
X-Workspace-Id。
迁移阶段 5:安全收敛
- 添加跨 workspace 越权测试。
- 添加 API Key scope 测试。
- 添加插件 Host API 过滤测试。
- 添加 MCP 和 RAG 隔离测试。
安全边界
必须防的场景:
- 用户 A 修改 URL 中 bot uuid,访问用户 B workspace 的 bot。
- API Key 来自 workspace A,但调用 workspace B 的 bot。
- 插件通过
get_bots()枚举所有 workspace 的 bot。 - 插件通过
workspace_storage读取其它 workspace 的数据。 - MCP server 名称相同导致 session 复用。
- monitoring session_id 相同导致数据串租户。
- Space OAuth 登录时,同 email 账户被错误绑定到已有本地 account。
建议策略:
- 所有资源访问都使用
workspace_uuid + resource_id。 - 所有 service 方法入口做权限检查。
- 插件 Host API 的 workspace 不信任插件入参,只信任 query/runtime connection 上下文。
- API Key 只授予最小 scope,默认不允许成员管理。
- owner 角色不能被普通 admin 移除或降权。
实施优先级
P0:基础租户骨架
- Account uuid/status。
- Workspace / Membership / Invitation。
- RequestContext。
- JWT 改为 account uuid。
- 前端 current workspace header。
P1:资源行级隔离
- Bots、Pipelines、Models、MCP、Plugins、Knowledge、Monitoring、API Keys 全部加 workspace_uuid。
- service 查询统一加 workspace filter。
- 权限矩阵落地。
P2:运行时隔离
- Query、Session、RuntimeBot、RuntimePipeline 加 workspace。
- Plugin Host API 和 MCP tools 按 workspace 过滤。
- SDK workspace storage 从
default改为真实 workspace。
P3:协作体验
- 邀请成员。
- 成员列表和角色管理。
- workspace switcher。
- 最近使用 workspace。
P4:SaaS 运维增强
- Workspace 级用量统计。
- Workspace 级限额:max_bots/max_pipelines/max_extensions/tokens/storage。
- 审计日志。
- workspace suspend/delete。
- 可选自定义角色。
测试计划
后端测试:
- 账户可加入多个 workspace。
- 同账户在不同 workspace 权限不同。
- viewer 不能创建/修改资源。
- API Key 只能访问所属 workspace。
- 所有资源 list/get/update/delete 都不能跨 workspace。
- 默认 workspace 迁移后旧数据可用。
运行时测试:
- 两个 workspace 使用相同
launcher_id不共享 session。 - 两个 workspace 使用相同 MCP server name 不共享 MCP session。
- 插件
get_bots()只能看到当前 workspace bot。 - 插件
workspace_storage在不同 workspace 读写隔离。 - Pipeline 只调用当前 workspace 绑定的插件和 MCP tools。
前端测试:
- 登录后自动进入最近 workspace。
- 切换 workspace 后列表数据变化。
- 无权限按钮隐藏,直接调用 API 也被后端拒绝。
- 邀请成员流程完整。
迁移测试:
- SQLite 老实例迁移。
- PostgreSQL 老实例迁移。
- 已有 local account 迁移为 default workspace owner。
- 已有 Space account token 和 Space model provider API key 不丢失。
关键实现注意事项
- 不建议在第一版做数据库 schema-per-tenant。LangBot 当前 ORM 和运行时均以单库单表为主,先做 shared schema + workspace_uuid 成本更低。
- 不建议每个 workspace 立即启动独立 plugin runtime。先共享 runtime,强制 action 带 workspace;大客户隔离可作为后续部署形态。
- 不要只在前端过滤 workspace。插件、API Key、MCP、RAG 都能绕过前端,必须在后端和运行时层过滤。
metadata要拆清楚:wizard status 属于 workspace,系统版本/迁移信息属于 system。users.user用 email 当主键语义不稳,应尽快引入account_uuid并让 JWT 以 uuid 为准。plugin_settings当前主键没有 workspace,改造时要先改主键/唯一约束,否则同插件无法在多个 workspace 配不同配置。
建议落地顺序
- 新增 workspace/account/membership 表和 RequestContext。
- 迁移旧数据到 default workspace。
- 改 auth 和前端请求头,让每个请求都有 current workspace。
- 从最核心资源开始逐个加 scope:bot -> pipeline -> provider/model -> plugin/MCP -> knowledge -> monitoring。
- 改 SDK Query/Event 和 runtime storage。
- 上成员管理 UI 和邀请。
- 做越权测试和迁移测试。
这个顺序的好处是可以较早让主 UI 在一个 workspace 下继续工作,同时把最危险的跨租户泄露面逐步收紧。