BISHENG 多租户资源所有者交接(F018)设计与实现指南
【免费下载链接】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 版本中的F018-resource-owner-transfer(所有者交接 API)特性规格展开,完整讲解其用户故事、验收标准、交接路径约束、架构决策、核心服务实现、HTTP API 设计、错误码体系与自测清单,并结合当前仓库的真实源码与测试用例进行纵深佐证。读完本文,你将掌握:如何通过
transfer-owner接口在 Tenant 内批量转交资源所有权、可见集合(INV-T10)规则如何工作、MySQL 与 OpenFGA 双写事务如何保证一致性、以及 19601~19606 错误码各自触发的条件与规避方式。
1. 特性背景与用户故事
在多租户体系中,员工离职、调岗是高频场景。PRD Review P0-C 决策"用户归属跟主部门切,数据留原处不动"——即用户的主部门(叶子 Tenant)变更时,其名下资源并不跟随迁移。这一决策虽然避免了数据搬运的复杂性,却带来一个必须配套解决的新问题:
谁来接盘离职/调岗员工在某个 Tenant 下遗留的资源?
F018 正是为此设计的所有者交接能力。其用户故事可以概括为:
作为离职员工 / 调岗员工 / 管理员, 我希望将某用户在本 Tenant 下的资源所有权批量转交给其他成员, 以便调岗/离职时不留孤儿资源,保障集团核心知识库的连续性。
在 BISHENG 中,这意味着一次接口调用同时完成两类所有权变更:
- MySQL 数据面:批量更新 7 类资源表的
user_id字段; - OpenFGA 权限面:批量删除原 owner 的
owner元组、写入新 owner 的owner元组。
两者必须一致成功,否则会出现"数据归新人、权限还挂在老人名下"的撕裂状态——这正是本特性核心架构决策(AD-03)要解决的问题。
2. 验收标准(AC)总览
F018 的规格文档定义了 11 条验收标准,覆盖权限、批量、可见性、事务与 UI 等维度:
| ID | 角色 | 操作 | 预期结果 |
|---|---|---|---|
| AC-01 | 资源原 owner | POST /tenants/{tid}/resources/transfer-owner自己转交 | 成功;返回transferred_count;audit_log记录 |
| AC-02 | Child Admin | 代替下属 owner 转交 | 成功(tenant admin 权限放行) |
| AC-03 | 非 owner 非 admin | 尝试转交他人资源 | HTTP 403 错误码19601 |
| AC-04 | 调用者 | 指定resource_ids=null | 自动转交from_user在tenant_id下所有可转移资源 |
| AC-05 | 调用者 | 指定resource_ids列表 | 仅转交列表中的资源 |
| AC-06 | 开发 | MySQL 写成功但 OpenFGA 失败 | 事务回滚;写入failed_tuples队列重试 |
| AC-07 | 调用者 | 单次请求 > 500 条 | HTTP 400 错误码19602提示分批 |
| AC-08 | 调用者 | to_user叶子 Tenant 不在tenant_id可见集合内 | 拒绝 HTTP 400 错误码19603;可见集合 ={tenant_id, tenant_id 的 Root}(INV-T10 / PRD §5.6.3.1) |
| AC-08b | — | from 在 Child A、to 在 Root(同 Root 子树) | 允许;典型"交回总部"场景;FGA owner 元组直接转移 |
| AC-08c | — | from 在 Child A、to 在 Child B(同 Root 但不同 Child) | 拒绝;to_user叶子=Child B ∉ {Child A, Root};返回19603 |
| AC-08d | — | from 在 Root、to 在 Child(资源下沉场景) | 拒绝;资源tenant_id=Root 时可见集合 = {Root};to_user叶子=Child ∉ 集合;返回19603;响应体提示走 F011 迁移 API |
| AC-09 | 用户个人中心 | 访问"我的资源"→"转交给..." UI | 可批量选择资源 + 接收人 |
| AC-10 | 全局超管 | 访问"待交接资源"列表 | 展示离职/超期未交接的资源清单 |
设计要点:AC-01 与 AC-02 说明交接的主体是"资源原 owner"或"具备 admin 权限的代发者";AC-03 明确非 owner 非 admin 的第三方一律 403。权限判定完全收敛在服务层,HTTP 层只做映射(详见第 5 节源码佐证)。
3. 支持的交接路径与边界情况
3.1 支持的交接路径(INV-T10 / PRD §5.6.3.1)
| 路径 | 场景 | 结论 |
|---|---|---|
| 同 Tenant 内 | from_user与to_user都在tenant_id | ✅ 典型"同事接手"场景 |
| Child → Root | from_user在 Child,to_user在 Root | ✅ 典型"交回总部"场景 |
| Root → Child(资源下沉) | — | ❌ 2026-04-21 移除;走 F011 迁移 API,不走本接口 |
从源码实现看,可见集合校验与上述路径一一对应(resource_ownership_service.py):
@classmethod async def _check_receiver_visible(cls, to_user_id: int, tenant_id: int) -> None: """AC-08 / INV-T10: receiver's leaf tenant must be in ``{tenant_id, ROOT_TENANT_ID}``.""" leaf = await cls._resolve_leaf_tenant(to_user_id) allowed = {tenant_id, ROOT_TENANT_ID} if leaf not in allowed: raise ResourceTransferReceiverOutOfTenantError()当tenant_id为 Root 时,allowed = {Root},Child 叶子用户必然被拒(AC-08d);当tenant_id为 Child 时,allowed = {Child, Root},恰好放行"交回总部"(AC-08b)、拒绝"横向跨 Child"(AC-08c)。接收人叶子 Tenant 的解析依赖UserTenantDao.aget_active_user_tenant(F011 提供),无活动记录时回退到 Root(例如新注册尚未 SSO 同步的用户)。
3.2 边界情况清单
transfer_types不含的资源:忽略(例如 session / message 不支持 owner)。to_user已是 owner:幂等 skip。- 部分资源无 owner 元组:跳过并记录(可能是 v2.4 遗留未迁移资源,记入告警)。
- 不支持的场景:
- 跨 Root 子树交接(即不支持把资源从一个集团转给另一个集团;本实例只有一个 Root,物理上不存在此场景);
- 同 Root 内 Child A → Child B 直接交接(应先 Child A → Root 交回总部,再通过 F011
/tenants/{B_id}/resources/migrate-from-root迁移tenant_id两步走;避免出现"飞地"资源); - Root → Child(资源下沉):本 API 的
tenant_id参数 = 资源所在 Tenant = Root,其可见集合 ={Root}不含 Child,to_user在 Child 会被 AC-08 拒绝。资源下沉请走 F011POST /api/v1/tenants/{child_id}/resources/migrate-from-root接口(改资源tenant_id),再于 Child 内交接 owner; - 交接后撤回(需二次交接回去);
- 自动调岗时转交(用户须主动发起或 Admin 代发)。
注意:AC-08d 是 2026-04-21 对原规格 §3 的修正——早期版本声称支持 Root→Child 下沉,这与 AC-08 的可见集合规则自相矛盾,因此统一收窄为"拒绝 + 引导走 F011"。
4. 架构决策(AD)
| ID | 决策项 | 选项 | 结论 |
|---|---|---|---|
| AD-01 | 交接操作权限 | A: 仅 owner 本人 / B: 本人 + Admin 代发 | B(支持离职场景) |
| AD-02 | 批量上限 | A: 无限制 / B: 500 条 / C: 100 条 | B(权衡 FGA Write 性能) |
| AD-03 | 失败策略 | A: 整批回滚 / B: 局部成功 + 补偿 | A(避免部分交接导致的不一致) |
三项决策在源码中均有明确落点:
- AD-01→
_check_operator三道闸门:owner 本人放行 →is_global_super放行 →is_admin放行,否则抛19601(见 resource_ownership_service.py)。 - AD-02→
MAX_BATCH = 500常量。注释说明了权衡依据:500 条既保持在 MySQLmax_allowed_packet之内,又处于 OpenFGA 单请求 100 条上限的可分块范围内(PermissionService内部会自动分块)。 - AD-03→ 事务性双写:MySQL 批量 UPDATE 与 FGA 元组翻转在同一流程内完成,任一步失败抛出
19605表示"已全部回滚";FGA 写入使用crash_safe=True,在调用 FGA 前先预写failed_tuples,进程若在 MySQL 提交后、FGA 写入前崩溃,由 F004 的补偿 worker 从failed_tuples重放。
5. 核心服务实现(源码级)
规格 §5 给出了ResourceOwnershipService的设计骨架,仓库中的真实实现位于 resource_ownership_service.py,文件头注释明确标注"Implements spec AC-01~AC-10 / AC-08b/c/d"。
5.1 服务入口与校验流水线
transfer_owner的完整调用顺序如下:
@classmethod async def transfer_owner( cls, tenant_id: int, from_user_id: int, to_user_id: int, resource_types: List[str], resource_ids: Optional[List[Union[int, str]]] = None, reason: str = '', operator: Any = None, ) -> Dict[str, Any]: # 1. Fast validations(先于任何 DB I/O) if from_user_id == to_user_id: raise ResourceTransferSelfError() # 19606 for rt in resource_types: get_meta(rt) # 19604 未知类型 # 2. Operator permission gate(AC-03 → 19601) cls._check_operator(operator, from_user_id) # 3. Receiver visible-set check(INV-T10 / AC-08 → 19603) await cls._check_receiver_visible(to_user_id, tenant_id) # 4. Resolve resources owned by from_user within tenant resources = await cls._resolve_resources( tenant_id, from_user_id, resource_types, resource_ids, ) if len(resources) > MAX_BATCH: raise ResourceTransferBatchLimitError() # 19602 if not resources: return {'transferred_count': 0, 'transfer_log_id': None} # 5. Transactional flip: MySQL → OpenFGA (crash_safe) → audit transfer_log_id = cls._make_transfer_log_id() try: await cls._bulk_update_user_ids(resources, to_user_id) await cls._flip_fga_owner_tuples(resources, from_user_id, to_user_id) except Exception as exc: raise ResourceTransferTxFailedError() from exc # 19605 await cls._safe_audit(...) return {'transferred_count': len(resources), 'transfer_log_id': transfer_log_id}设计上的几个关键取舍:
- 先校验后 I/O:
from_user_id == to_user_id(19606)与未知资源类型(19604)在触碰数据库之前就短路,避免无谓的 SQL 开销。 - 解析后才校验批量上限:AC-07 的 500 条上限作用于"实际解析出的资源数",而非请求里的
resource_ids长度。 - 空结果快速返回:
resources为空时返回transferred_count=0,不产生任何 FGA 或审计副作用(测试test_receiver_child_to_root_allowed专门验证了这一点)。
5.2 资源解析与批量更新
_resolve_resources在bypass_tenant_filter()上下文中,对每种资源类型执行:
SELECT id, user_id, tenant_id FROM {meta.table} WHERE user_id = :uid AND tenant_id = :tid [AND {meta.type_filter_sql}] [AND id IN :ids] -- 仅当显式指定 resource_ids 时批量更新则按类型分组,对每张表执行UPDATE ... SET user_id = :uid WHERE id IN :ids,最后统一commit(),异常时rollback()后重新抛出。值得一提的细节是:folder与knowledge_file共享物理表knowledgefile,通过各自的type_filter_sql区分(file_type = 0/file_type = 1),因此分组维度是resource_type而非物理表名,保证 SELECT 与 UPDATE 的 SQL 对称、审计计数与类型口径一致。
5.3 资源类型注册表(7 类 MVP)
规格指出 MVP 收窄为 7 类资源(Dashboard 因暂无 ORM 延后)。仓库用 resource_type_registry.py 作为唯一事实源,未来新增类型只需在此注册:
| resource_type | 物理表 | id_type | type_filter_sql | 说明 |
|---|---|---|---|---|
knowledge_space | knowledge | int | type = 3 | 仅 KnowledgeTypeEnum.SPACE 可转移,排除 chat/QA/private 变体 |
folder | knowledgefile | int | file_type = 0 | FileType.DIR |
knowledge_file | knowledgefile | int | file_type = 1 | FileType.FILE |
workflow | flow | str(UUID) | flow_type = 10 | FlowType.WORKFLOW |
assistant | assistant | str(UUID) | 无 | 独立表 |
tool | t_gpts_tools | int | is_delete = 0 | 排除墓碑记录 |
channel | channel | str(CHAR(36)) | 无 | 独立表 |
该注册表同时约束了两层防线:
- 服务层:
get_meta()对未知类型抛19604; - DTO 层:请求模型
TransferOwnerRequest.resource_types使用Literal['knowledge_space','folder','knowledge_file','workflow','assistant','tool','channel']做 FastAPI 原生校验,未知值在进入 handler 之前就返回 HTTP 422(测试test_unsupported_type_rejected_at_dto_level验证了dashboard被拒)。
另外_coerce_ids会静默丢弃与目标表id_type不匹配的 id(例如把数字 id 传给 UUID 表的场景),避免ValueError的同时保持"查不到即不转移"的语义。
5.4 FGA owner 元组翻转
_flip_fga_owner_tuples为每个资源构造一对操作:
ops.append(TupleOperation(action='delete', user=f'user:{from_user_id}', relation='owner', object=f'{r.resource_type}:{r.id}')) ops.append(TupleOperation(action='write', user=f'user:{to_user_id}', relation='owner', object=f'{r.resource_type}:{r.id}')) await PermissionService.batch_write_tuples(ops, crash_safe=True)crash_safe=True是 F004(REBAC 核心)提供的崩溃安全双写能力:先预写failed_tuples,再调 FGA;FGA 成功后清除预写记录。若进程在 MySQL 提交之后、FGA 生效之前崩溃,补偿 worker 会依据failed_tuples重放,满足 AC-06 的"最终一致"诉求。
5.5 审计日志
审计动作常量定义于 tenant/domain/constants.py:RESOURCE_TRANSFER_OWNER = 'resource.transfer_owner'。审计写入是best-effort(_safe_audit):主事务已提交,审计失败只记日志、不阻断成功返回。operator_tenant_id的解析规则为:优先取admin_scope_tenant_id(F019 全局超管切换管理视图);否则超管默认 Root、普通 admin/用户取自身叶子;未知形状回退资源tenant_id。
6. HTTP API 设计
规格 §6 定义了两个端点,真实路由实现在 resource_owner_transfer.py。
6.1 POST 转交接口
POST /api/v1/tenants/{tenant_id}/resources/transfer-owner Auth: 资源 owner 本人 OR tenant admin OR 全局超管请求体(对应TransferOwnerRequest,见 tenant_schema.py):
{ "from_user_id": 123, "to_user_id": 456, "resource_types": ["knowledge_space", "workflow"], "resource_ids": null, "reason": "张三调岗到子公司 X" }字段约束:
| 字段 | 类型 | 约束 |
|---|---|---|
from_user_id | int | > 0 |
to_user_id | int | > 0,且不得等于from_user_id |
resource_types | string[] | 必须为 7 类注册类型之一,min_length=1 |
resource_ids | string[] | 可选;null表示转交全部可转移资源;混用 int/UUID 字符串均可(服务层按表id_type自动过滤) |
reason | string | 可选,max_length=1000 |
成功响应:
{"transferred_count": 18, "transfer_log_id": "txn_20260420_abc123"}transfer_log_id格式为txn_YYYYMMDD_<8hex>(源码_make_transfer_log_id),与 PRD §5.6.3.1 示例txn_20260420_abc123一致,可作为审计日志的target_id。
6.2 GET 待交接列表接口
GET /api/v1/tenants/{tenant_id}/resources/pending-transfer Auth: 全局超管 / Child admin返回示例:
{"list": [{"user_id": 123, "user_name": "...", "resource_count": 8, "relocated_at": "..."}]}实现上(list_pending_transfer)在bypass_tenant_filter()下对 7 张注册表按tenant_id聚合COUNT(*),再逐一比对每个用户的当前叶子 Tenant:叶子 ≠tenant_id即判定为"已调离却仍持有资源",进入待交接列表。规格说明 MVP 不设资源年龄阈值,"超期"过滤由调用方自行实现。该端点会泄露离职/调岗用户的 id,因此仅 admin/超管可访问——端点代码明确拒绝普通用户并返回 19601。
6.3 错误码 → HTTP 状态映射
端点为"结构性请求错误"映射 400、"权限闸门"映射 403、"事务失败"映射 500:
| 错误码 | 含义 | HTTP |
|---|---|---|
| 19601 | 无权限转交(非 owner 且非 admin) | 403 |
| 19602 | 超过批量上限(500 条) | 400 |
| 19603 | 接收人叶子 Tenant 不在资源 tenant_id 的可见集合内 | 400 |
| 19604 | 资源类型不支持交接 | 400 |
| 19605 | MySQL/OpenFGA 事务失败已回滚 | 500 |
| 19606 | from_user_id 等于 to_user_id | 400 |
错误码类定义集中在 resource_owner_transfer.py,错误码模块MMM=196 (resource_owner_transfer),每个类的Code与Msg与上表一一对应;19605之所以映射 500,是让调用方能够区分"事务性失败(可重试前先排查 FGA 健康)"与"其他未处理异常"。
7. 依赖关系
F018 自身不实现 Root→Child 下沉逻辑(AC-08d 命中场景需引导调用方走 F011 迁移 API),其依赖如下:
- F011-tenant-tree-model:Tenant 模型 +
POST /api/v1/tenants/{child_id}/resources/migrate-from-root资源下沉端点(INV-T10);同时提供接收人叶子 Tenant 解析所需的UserTenantDao.aget_active_user_tenant; - F013-tenant-fga-tree:FGA
batch_write能力; - v2.5.0/F004-rebac-core:
FailedTuple补偿机制(crash_safe=True双写)。
服务实现中刻意保持最小依赖:超管判定优先走operator.is_global_super(),不存在时回退is_admin()(F011 mount 服务采用同一 guard,F013 会进一步收紧为is_tenant_admin),保证 F018 落地不被 F012/F013 阻塞。
8. 自测清单与测试验证
规格 §8 要求开发者实现后自行运行测试。仓库中已存在两套真实测试,可作为回归依据:
8.1 服务层测试
test_resource_ownership_service.py 覆盖 AC-01/03/04/05/06/07/08/08b/08c/08d 与 AD-02/AD-03,策略为:SQL 路径(解析、批量更新、待交接列表)走真实 SQLite;FGA 与审计 DAO 用AsyncMock;UserTenantDao.aget_active_user_tenant按用例打桩以控制接收人叶子(AC-08 各分支)。关键用例包括:
test_self_transfer_rejected_19606:from == to → 19606;test_unsupported_type_rejected_19604:dashboard(已移出 MVP)→ 19604;test_non_owner_non_admin_rejected_19601:闯入者转交他人资源 → 19601;test_receiver_cross_child_rejected_19603/test_receiver_root_to_child_rejected_19603:AC-08c/08d;test_receiver_child_to_root_allowed:AC-08b,无资源时 0 转移、FGA 与审计均不触发;test_batch_over_500_rejected_19602:种入 501 条 workflow → 19602;test_resource_ids_null_selects_all_from_user/test_resource_ids_list_filters_to_named:AC-04/05;test_happy_path_updates_mysql_fga_audit:验证 MySQLuser_id翻转、FGA 恰好 2 删 2 写、审计action='resource.transfer_owner'与 reason 透传;test_fga_failure_rolls_back_and_raises_19605:FGA 抛错 → 19605 且审计不写入;test_pending_includes_users_whose_leaf_moved:AC-10,叶子已移走的用户被列出;test_max_batch_is_500/test_resource_row_is_frozen:常量回归守卫与不可变约束。
8.2 HTTP 层测试
test_resource_owner_transfer_api.py 只挂载 F018 路由的最小 FastAPI 应用,Mock 服务层,验证路由、鉴权注入、DTO 校验与错误码→HTTP 映射:owner 本人 200、Child Admin 200、随机用户 403+19601、resource_ids透传、>500 → 400+19602、跨 Child 与 Root→Child → 400+19603、dashboard→ FastAPI 原生 422、自转 → 400+19606、事务失败 → 500+19605、待交接列表仅 admin/超管可访问。
8.3 UI 项
AC-09(个人中心"我的资源→转交给...")与 AC-10(超管"待交接资源"列表)标注为 Playwright E2E,待前端测试框架搭建后补充;在此之前由开发者本地启前端逐项手动自测并截图附入 PR。
9. 小结
F018 通过"服务层单一事务 + FGA 崩溃安全双写 + 可见集合收口"三件套,为 BISHENG 多租户体系提供了可靠的资源所有者交接能力:
- 权限收口:owner / tenant admin / 全局超管三道闸门全部收敛在
ResourceOwnershipService,HTTP 层只做透传与错误码映射; - 路径收口:仅允许"同 Tenant 内"与"Child → Root",其余场景一律 19603 拒绝并引导走 F011 迁移 API,杜绝跨集团与"飞地"资源;
- 一致性收口:MySQL
user_id翻转与 OpenFGA owner 元组翻转成对出现,任一步失败整体回滚并上报 19605,配合failed_tuples补偿队列兜底进程崩溃窗口。
如需深入源码,建议按以下路径阅读:服务实现 resource_ownership_service.py → 类型注册表 resource_type_registry.py → 端点 resource_owner_transfer.py → 错误码 resource_owner_transfer.py → DTO tenant_schema.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),仅供参考