设计与实现解读)
BISHENG 多租户体系中的用户组按租户隔离F023设计与实现解读【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng本篇文章基于开源仓库 BISHENG企业级 LLM DevOps 平台v2.5.1 迭代中 F023-user-group-tenant-isolation 规格文档 展开结合当前仓库的group模型、UserGroupService服务、租户过滤事件钩子与错误码模块等源码完整讲解用户组User Group从全局共享表演进为按租户隔离这一多租户改造的设计骨架、验收标准、实现方向与落地现状。读者读完可以掌握 BISHENG 多租户体系中业务资源隔离的通用改造套路Schema 迁移 → Service 过滤 → 跨租户拦截 → 错误码 → 网关同步并能在自己项目中复用这套先放行 UI 入口、再补后端隔离的渐进式治理方法。1. 背景用户组 tab 对子租户管理员放行后的遗留缺口在 v2.5.1 迭代中与 F023 同日提交的 commitfeat(tenant): scope dept treepermission to Child Admin完成了两件事把「用户组管理」tab 对Child Admin子租户管理员显示放行_can_open_user_group_management权限检查让租户管理员能够进入用户组相关接口。从当前仓库源码看_can_open_user_group_management的判定逻辑位于 user_group_service.py系统超管_is_admin→ 任一部门的部门管理员_is_department_admin走 OpenFGA→ 子租户管理员_is_tenant_admin三者任一命中即可进入用户组管理 API。问题在于入口虽然放开了但用户组实体本身在当时仍是全局表——group表没有tenant_id字段UserGroupService的alist_groups/aget_group/acreate_group既不按租户过滤、也不按租户写入。这带来三个直接后果可见性越权Child Admin 进入「用户组管理」tab 看到的是全部用户组包括 Root 的、其他 Child 的归属不明Child Admin 创建的用户组没有挂载 tenant成为无主资源成员越权Child Admin 理论上可把别 tenant 的用户加入自己的组除非另有权限拦截。F023 正是承接这个遗留缺口的专项 Feature让后端user_group资源真正按 tenant 隔离。它的定位很明确——不是从零设计用户组功能那是 F003 的范畴而是把 F003 的产物嵌入 F001多租户基础 F011Tenant 树 F019admin-scope构成的多租户框架中。1.1 相关依赖链依赖内容与 F023 的关系F001多租户基础Tenant / UserTenant 基础表 ContextVar提供租户上下文基础F011Tenant 树模型parent_tenant_id/share_default_to_children字段、RootChild 两层拓扑、IN 列表过滤决定Root 共享给 Child的语义与可见范围F019admin-scope全局超管的管理视图切换_admin_scope_tenant_id决定 Sys Admin 以某 tenant 视角操作时的取值来源2. 验收标准完整继承原文档F023 规格文档给出了 6 条验收标准草稿这是判断改造是否完成的可执行契约ID角色操作预期结果AC-01Child Admin (tenant5)GET /api/v1/user-groups/200仅返回tenant_id ∈ {5, 1*}的组*Root 共享策略待定参考 F011share_default_to_childrenAC-02Child Admin (tenant5)POST /api/v1/user-groups/创建组201新组tenant_id5自动写入AC-03Child Admin (tenant5)PUT /api/v1/user-groups/{id}改 Root 组403 19xxx错误码待分配AC-04Sys AdminGET /api/v1/user-groups/200返回全部组无 tenant 过滤AC-05Sys Adminadmin-scope5GET /api/v1/user-groups/200以 tenant5 视角返回与 AC-01 等价AC-06Child Admin把别 tenant 用户加入自己组403这 6 条 AC 分别覆盖了多租户隔离的四个典型维度读隔离AC-01 / AC-04 / AC-05不同身份看到不同的组集合且 Sys Admin 的管理视图能等价于某个 Child 的视角写归属AC-02新建资源自动打上当前租户标签写拦截AC-03跨租户修改被拒绝成员边界AC-06组的成员集合不允许跨租户扩张。接口路径均挂载在/api/v1/user-groups前缀下与当前仓库 router.py 中的APIRouter(prefix/user-groups)一致。3. 现状盘点当前仓库里已经发生了什么F023 规格文档自身标注为占位 stub2026-04-26 创建意味着它是排期前的设计占位。但从当前仓库源码看规格中设想的若干改造点已经部分落地这是阅读本文时需要特别注意的文档 vs 代码时间差。以下逐一对照。3.1group表tenant_id 字段与租户内唯一命名已存在F023 设计方向第 1 条是Schema 迁移group表增加tenant_id存量组归 Roottenant_id1加索引挂载 alembic 迁移。从当前源码看这一步已经在模型层完成。查看 group.pyclass Group(GroupBase, tableTrue): __tablename__ group id: Optional[int] Field(defaultNone, primary_keyTrue) tenant_id: Optional[int] Field( defaultNone, sa_columnColumn(Integer, nullableFalse, server_defaulttext(1), indexTrue, commentTenant ID), ) __table_args__ ( UniqueConstraint(tenant_id, group_name, nameuk_tenant_group_name), )几个值得注意的细节tenant_id的server_default1意味着存量数据默认归 Roottenant_id1与 F023 设计方向 1 的把存量组归 Root一致indexTrue对应设计的INDEX idx_group_tenant_id唯一约束uk_tenant_group_name把组名唯一从全局粒度收窄为同一租户内组名唯一这正是 F011 23 张表改造清单第 14 项group表标注的租户内唯一命名落地形态同时GroupDao.acheck_name_duplicategroup.py的注释也明确写着 Check if group_name already exists within current tenant而aget_all_groupsgroup.py的 docstring 是 Paginated list of all groups for current tenant (auto-filtered)——auto-filtered正是依赖下文要讲的租户过滤事件钩子。3.2 Service 层读取过滤与写入归属的现状F023 设计方向第 2 条要求alist_groups/aget_group过滤、acreate_group从login_user.tenant_id或get_current_tenant_id()兼容 admin-scope写入。当前 user_group_service.py 的实际形态是alist_groupsL250-L261先判断_can_view_all_groups系统超管 / 租户管理员返回 True命中则查全部否则走aget_visible_groups公开组 创建者组 成员组。注意这里的全部在租户过滤事件钩子生效时已经被自动限制在当前可见租户集合内acreate_groupL220-L247创建Group对象时没有显式赋值tenant_id——正是依靠before_flush事件钩子自动填充_can_view_all_groupsL75-L82与 F019 admin-scope 的兼容逻辑在租户上下文层完成见第 4 节。这里能清晰看到 BISHENG 多租户改造的架构取向Service 层尽量不手写 tenant 判断而是依赖 SQLAlchemy 全局事件钩子做自动注入Service 只保留谁能看全部/谁能管理的业务语义。3.3 网关侧同步purge_user_group_residual_syncF023 设计方向第 5 条提到_sync_user_group_delete_side_effects中的 Redis 通知需要带 tenant 上下文。当前仓库中该函数的实际名称为purge_user_group_residual_sync位于 user_group_service.pydef purge_user_group_residual_sync(group_id: int) - None: Notify the gateway of a user-group deletion. delete_message json.dumps({id: group_id}) redis_client get_redis_client_sync() redis_client.rpush(delete_group, delete_message, expiration86400) redis_client.publish(delete_group, delete_message)删除用户组时成员关系行由GroupDao.adeletegroup.py与组行在同一个事务中原子删除同时向 Redis 的delete_group队列投递删除通知带 86400 秒过期供网关订阅端清理下游缓存/权限快照。F023 要评估的是通知消息中是否需要携带tenant_id以及网关订阅端如何据此做租户维度的清理。从谁删的组归谁管的原则出发带上租户上下文可以避免网关侧全量扫描。3.4 错误码module 230 现状与 19xxx 的冲突澄清F023 设计方向第 6 条要求新增UserGroupCrossTenantError19xxx 待分配。从当前 user_group.py 看用户组模块的错误码模块编码是230错误类编码含义UserGroupNotFoundError23000用户组不存在UserGroupNameDuplicateError23001组名在当前租户内已存在UserGroupMemberExistsError23004用户已是组成员UserGroupMemberNotFoundError23005用户不是组成员UserGroupPermissionDeniedError23006无该用户组操作权限UserGroupNoSeparateAdminsError23007用户组不再有独立管理员仅创建者可管理规格中19xxx与源码现状存在矛盾F011 规格文档第 9 节曾明确指出MMM190 与 F004 permission 模块已上线的19000~19005直接冲突并于 2026-04-19 重新分配到 220tenant_tree 模块。因此 F023 若沿用19xxx会造成模块编码冲突——从仓库的既有决策看跨租户错误码更合理的做法是并入用户组自己的230模块如 23008/23009 预留位或参照 220 模块先登记再分配。这一点在实施 F023 时需以 release-contract 的模块编码约定 为准避免重蹈 190 冲突覆辙。4. 底层机制租户过滤事件钩子如何自动完成隔离F023 之所以能把大量隔离逻辑藏在 Service 层背后核心依赖是 tenant_filter.py 注册的两个 SQLAlchemy 全局 Session 事件4.1do_orm_execute查询自动注入 WHERE事件监听器拦截所有 ORM SELECTtenant_filter.py按以下优先级注入租户条件bypass 优先is_tenant_filter_bypassed()为真时跳过过滤系统超管跨租户查询、初始化代码用可见租户 IN 列表get_visible_tenant_ids()返回非 None 时注入tenant_id IN (leaf, root, ...shared_to)列表长度为 1 时退化为等值条件当前租户等值否则注入tenant_id get_current_tenant_id()多租户关闭时回落到DEFAULT_TENANT_ID1开启但无上下文时抛NoTenantContextError。租户感知表不是硬编码清单而是通过_discover_tenant_aware_tablestenant_filter.py在 SQLModel metadata 中自动发现带 tenant_id 列的表user_tenant除外因为它的 tenant_id 是 FK 而非隔离字段。同时_TENANT_AWARE_MODEL_MODULEStenant_filter.py强制预导入所有租户感知模型模块——其中明确包含bisheng.database.models.group确保group表在事件注册前就进入 metadata不会出现模型没被路由链导入 → 过滤静默失效的泄漏坑。4.2before_flush写入自动填充 tenant_id插入前事件tenant_filter.py对session.new中的每个新对象自动填充tenant_id对象上该字段为 None 或 0 时写入get_current_tenant_id()解析出的当前租户。这就是 AC-02新组 tenant_id 自动写入的落地机制——acreate_group无需手动传 tenant事件钩子代劳。4.3 租户上下文admin-scope 兼容的关键tenant.py 用 ContextVar 承载请求级租户上下文由 HTTP 中间件JWT cookie、WebSocket 中间件、Celery 任务钩子分别设置。其中与 F023 直接相关的两个能力get_current_tenant_id()tenant.pyF019 admin-scope 覆盖优先——全局超管设置_admin_scope_tenant_id后返回该值管理视图否则返回 JWT 叶子租户。这正是 AC-05Sys Admin 以 tenant5 视角返回的实现基础超管切到管理视图后get_current_tenant_id()返回 5过滤钩子与写入钩子都会按 5 生效与 Child Admin 视角等价get_visible_tenant_ids()tenant.py返回当前请求可见的租户 ID 集合Root 用户为{1}Child 用户为{leaf_id, 1}无 admin-scope 的全局超管为None不过滤。AC-01 中仅返回tenant_id ∈ {5, 1*}的1*共享语义就落在这里——若 Root 组通过共享策略进入 Child 的可见集合列表接口自然返回。5. 六个设计方向逐一展开原文档 §3 的深化F023 原文档给出了 6 条设计方向以下结合仓库现状逐条深化并标注当前落地程度与实施要点。5.1 方向 1Schema 迁移原设计group表增加tenant_id INT NOT NULL DEFAULT 1加INDEX idx_group_tenant_id存量组归 Root挂载 alembic 迁移。现状与深化模型层已完成见 3.1 节且多加了uk_tenant_group_name唯一约束。实施提醒存量数据回填server_default1保证老行自动归 Root但显式 backfill 语句仍是推荐做法可参考 F011 迁移脚本v2_5_1_f011_tenant_tree.py的写法与column_exists幂等判断见 src/backend/bisheng/core/database/alembic/versions/v2_5_1_f011_tenant_tree.py唯一约束迁移风险若存量数据存在跨租户重名uk_tenant_group_name建立前必须先排查全局重名在归 Root 后天然满足唯一但需确认没有历史脏数据。5.2 方向 2Service 改造读过滤 写归属原设计alist_groups/aget_group过滤acreate_group从login_user.tenant_id或get_current_tenant_id()写入aupdate_group/adelete_group跨租户拦截。现状与深化读过滤已由 4.1 节的事件钩子自动完成aget_all_groupsdocstring 明确标注 auto-filtered写归属已由 4.2 节的before_flush自动完成尚未显式落地的是aupdate_group/adelete_group的跨租户拦截。当前实现依赖_ensure_mutate_groupuser_group_service.py的超管或创建者判定由于创建者的用户组必然归属其租户跨租户修改通常会被非创建者拦截但防御性显式校验group.tenant_id get_current_tenant_id()或可见集合包含该组仍值得补上尤其是考虑 admin-scope 场景下超管以 Child 视角改 Root 组这类边界。5.3 方向 3跨租户加成员拦截原设计aadd_members校验target_user.tenant_id group.tenant_id或允许 Root 用户加入任何组待决策。深化这条是 AC-06 的直接落点。当前aadd_membersuser_group_service.py只做权限判定_ensure_mutate_group_members 去重acheck_members_exist没有校验目标用户的租户归属。实施时建议批量查询UserDao.aget_user_by_ids拿到每个目标用户的叶子租户与group.tenant_id比对命中跨租户用户时抛 403 新错误码并返回失败用户清单与 F011 资源下沉 API 的{migrated: N, failed: [...]}响应风格一致Root 用户可加入任何组的特例需与 Root 共享语义决策一起敲定避免出现Root 用户加入了 Child 私有组的语义混乱。5.4 方向 4Root 共享语义原设计参考 F011share_default_to_children、F022tenant_system_model_config的Root 默认 Child 覆盖模式决定 Root 创建的组是否对所有 Child 可见。深化F011 的Tenant.share_default_to_children字段见 F011 spec是这条语义的锚点。在 F011 的过滤模型里Child 用户的可见集合是{leaf_id, 1}——即Root 资源默认对 Child 可见这对应 AC-01 中1*的默认形态。但用户组的可见性还叠加了visibilitypublic/private字段public 组Root 建的公开组Child 用户即使不在可见集合之外也会被aget_visible_groups的公开组条件命中private 组只有组成员/创建者可见跨租户成员被拦截后Child 用户自然看不到 Root 私有组。因此Root 共享语义的实际落点是决定tenant_id1的组进入 Child 可见集合的默认规则默认共享 vs 默认隔离以及是否复用share_default_to_children做租户级开关。从仓库既有模式看F022 的tenant_system_model_config、F029 回填脚本中对share_default_to_children0的尊重见 v2_5_1_f029_llm_shared_backfill.pyRoot 默认 可关闭是最符合既有架构的答案。5.5 方向 5网关侧同步评估原设计_sync_user_group_delete_side_effects的 Redis 通知带 tenant 上下文确认网关订阅端处理方式。深化当前函数已更名为purge_user_group_residual_sync见 3.3 节向 Redis 的delete_group通道rpushpublish。实施要点消息体从{id: group_id}扩展为{id: group_id, tenant_id: ...}是向后兼容的网关端可先按 id 处理tenant_id 作为增强字段删除用户组时成员关系由GroupDao.adelete事务内级联删除Redis 通知只承载下游同步职责不存在数据一致性风险需要确认网关订阅端的消费逻辑是否按租户隔离缓存 key——若网关侧已按 tenant 分片存储权限快照带上 tenant_id 可以精确清理避免误删他租户缓存。5.6 方向 6错误码原设计在bisheng/common/errcode/user_group.py新增UserGroupCrossTenantError19xxx。深化见 3.4 节。强烈建议不使用 19xxxF004 permission 模块已占用 19000~19005F011 曾因此把模块编码从 190 调整为 220。用户组模块自身是 23023000~23007 已用新增UserGroupCrossTenantError应分配23008或后续空位保持模块内聚。同时需在 tools/extract_errcodes_ast.py 的扫描体系下登记保证错误码 i18n 文案errcode_en_from_ast.json同步更新。6. 测试覆盖现有用户组测试的扩展空间F023 状态清单中的最后一项是测试覆盖。当前仓库已有用户组 API 测试 test_user_group_api.py覆盖了 CRUD、成员增删、成员同步等场景POST /user-groups/、GET /user-groups/、PUT /user-groups/{id}、DELETE /user-groups/{id}、POST /user-groups/{id}/members、DELETE /user-groups/{id}/members/{uid}等。F023 落地后建议在现有基础上按 AC 增补以下用例用例覆盖 AC关键断言Child Admin 列表只见本租户共享组AC-01响应不含其他 Child 的私有组Child Admin 创建组自动归属当前租户AC-02新建组tenant_id等于会话租户Child Admin 修改 Root 组返回 403AC-03HTTP 403 新错误码Sys Admin无 scope列表返回全部AC-04全局可见Sys Adminadmin-scope5列表等价 Child 视角AC-05与 AC-01 结果一致跨租户加成员被拦截AC-06HTTP 403组名跨租户可重名方向 1两个租户可各自建同名组测试时可复用仓库既有的app.include_router(user_group_router, prefix/api/v1)夹具模式test_user_group_api.py并通过设置set_current_tenant_id/set_visible_tenant_ids模拟不同租户上下文。7. 落地状态与排期建议F023 规格文档自身的状态清单§4目前全部未勾选并注明本 stub 提交后请在迭代会议把本 Feature 排期入正式 v2.5.1 / v2.5.2 计划。结合本文的源码盘点可以给出更精准的排期判断任务状态依据当前仓库源码Schema 迁移tenant_id 索引 唯一约束已基本落地group.py读取过滤alist/aget已通过事件钩子自动落地tenant_filter.py写入归属acreate已通过 before_flush 自动落地tenant_filter.py更新/删除跨租户显式拦截待补当前依赖创建者判定兜底跨租户加成员拦截待补AC-06 未实现Root 共享语义决策待决策可复用share_default_to_children模式网关同步带 tenant 上下文待评估purge_user_group_residual_sync消息体扩展错误码UserGroupCrossTenantError待新增建议并入 230 模块测试覆盖待补在 test_user_group_api.py 基础上扩展一个值得关注的架构结论由于 BISHENG 把租户隔离下沉到了 SQLAlchemy 事件层带 tenant_id 列的表自动过滤 自动填充F023 的读隔离与写归属两大块实际已被基础设施免费承接剩余工作集中在业务语义层——显式跨租户拦截、成员边界校验、共享策略决策与网关同步。这也是为什么原文档称本 Feature 是承接遗留缺口缺的不是隔离机制而是对user_group这一具体资源的语义补全。实施者落地 F023 时建议按AC 驱动 模块化提交推进先补错误码与跨租户拦截AC-03/AC-06再决策共享语义AC-01 的1*最后处理网关同步与测试确保每个提交都能对应到可验证的验收标准。参考文件索引规格文档features/v2.5.1/023-user-group-tenant-isolation/spec.md依赖 Featurefeatures/v2.5.1/011-tenant-tree-model/spec.mdTenant 树与共享语义、features/v2.5.1/019-admin-tenant-scope/spec.mdadmin-scope数据模型src/backend/bisheng/database/models/group.py业务服务src/backend/bisheng/user_group/domain/services/user_group_service.py租户过滤钩子src/backend/bisheng/core/database/tenant_filter.py租户上下文src/backend/bisheng/core/context/tenant.py错误码模块src/backend/bisheng/common/errcode/user_group.pyAPI 路由src/backend/bisheng/user_group/api/router.py测试用例src/backend/test/user_group/test_user_group_api.py迁移脚本参考src/backend/bisheng/core/database/alembic/versions/v2_5_1_f011_tenant_tree.py【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考