资讯动态

多租户菜单怎么分层?平台菜单、租户菜单与套餐能力的设计和验证

发布时间:2026/10/15 12:57:42 来源:尧图企业网站定制
同一个账号在平台管理页能看租户列表切到甲公司能管理成员切到乙公司却只能看自己的申请单。这不是“菜单配错了”而是多租户系统本来就有不同的执行身份和能力边界。真正的问题是如果菜单只按账号上的全局角色生成甲公司的管理入口很可能被带到乙公司如果只按套餐隐藏按钮用户仍可能直接调用接口。本文用一组固定数据说明菜单应如何生成、缓存和验证。重点是平台菜单与租户菜单不能混树页面、按钮与接口不是同一层授权套餐只限制功能可用性。示例采用 Java 17、Spring Boot 风格服务层和 MySQL 8.0.16 及以上版本文中的公司、账号和功能均为虚构不涉及实际产品配置。目录同一账号切换组织后菜单应该怎样变化把菜单拆成管理面、页面和动作菜单数据保存什么权限与套餐放在哪里生成菜单先确定身份再求可见节点按钮和接口如何保持一致测试、SQL核对与上线验收一、同一账号切换组织后菜单应该怎样变化沿用前文的虚构租户甲公司t_a、乙公司t_b。账号u_07在甲公司是租户管理员有成员查看、邀请和报表操作权限甲公司的套餐开通了批量报表。u_07在乙公司只是普通查看者只能看授权范围内的申请单乙公司没有开通批量报表。平台运营人员p_01有平台租户状态管理权限但没有任何租户业务数据的默认访问权。期望的导航与动作如下当前执行身份应显示的页面应显示的动作不应出现的内容p_01平台管理面租户列表暂停租户甲、乙公司的成员页和业务单据u_07甲公司租户面成员、批量报表、申请单邀请成员、运行报表平台租户状态管理u_07乙公司租户面申请单仅其业务授权允许的动作甲公司成员页、批量报表、平台管理如果u_07从甲公司切到乙公司后仍看到“邀请成员”说明菜单缓存可能只按account_id建键如果乙公司用户看不到“运行报表”按钮却能POST /tenant/reports/run则是接口缺少能力校验。两个故障的修复点不同前者在菜单投影与缓存后者在服务端执行授权。这里约定“看到页面”只表示有进入该页面的基础权限不等于页面上所有数据都可见。例如申请单详情还要按当前租户、动作权限和数据范围查询不能因为菜单上有“申请单”就放开所有记录。图1菜单结果由当前执行身份决定而不是从账号上取一份全局菜单。二、把菜单拆成管理面、页面和动作第三阶段已介绍过普通后台的目录、页面和按钮。到了多租户场景还要增加一个更外层的划分管理面。平台管理面处理租户目录、运行状态等平台对象租户管理面处理当前租户的成员与配置业务页面处理当前租户授权范围内的数据。平台人员并非所有租户菜单的上级用户。在这之内菜单节点可以分为三类节点作用例子授权含义分组GROUP导航归类租户管理、业务中心本身不授予下级页面权限页面PAGE可进入的路由成员列表、申请单列表需要页面动作权限和适用的套餐能力动作BUTTON页面内可触发操作邀请、暂停、运行报表需要独立动作权限执行时再由接口校验一个父分组即使被显示也不能推出全部子页面可访问反过来一个子页面可访问时系统可以把它的父分组作为导航容器补出来。按钮应挂在对应页面下不能因为用户能打开页面就自动拥有新增、删除或导出权限。套餐与动作权限解决不同问题。甲公司开通批量报表只说明该租户有使用资格u_07是否能运行还要看其成员角色是否授予report:run。乙公司未开通时即使某个旧角色配置里残留report:run入口也应隐藏接口也应拒绝。允许执行 当前执行身份适用 动作授权 租户能力有效 目标资源允许其中目标资源检查不能靠菜单预先完成。图2父分组用于组织导航页面、按钮和套餐能力各自回答不同的问题。三、菜单数据保存什么权限与套餐放在哪里菜单定义应保存稳定的导航元数据不要把“某租户当前开通了什么”硬写进全局菜单表。下面的最小结构刻意分开菜单目录与运行时授权事实CREATETABLEdemo_menu_catalog(menu_keyVARCHAR(80)PRIMARYKEY,parent_keyVARCHAR(80)NULL,planeVARCHAR(20)NOTNULL,-- PLATFORM / TENANTnode_kindVARCHAR(20)NOTNULL,-- GROUP / PAGE / BUTTONtitleVARCHAR(80)NOTNULL,routeVARCHAR(160)NULL,action_keyVARCHAR(80)NULL,capability_keyVARCHAR(80)NULL,sort_noINTNOTNULL,enabledBOOLEANNOTNULLDEFAULTTRUE,UNIQUEKEYuk_plane_menu(plane,menu_key),KEYidx_menu_parent(plane,parent_key,sort_no),CONSTRAINTfk_menu_parent_planeFOREIGNKEY(plane,parent_key)REFERENCESdemo_menu_catalog(plane,menu_key),CONSTRAINTck_menu_planeCHECK(planeIN(PLATFORM,TENANT)),CONSTRAINTck_menu_kindCHECK(node_kindIN(GROUP,PAGE,BUTTON)))ENGINEInnoDB;plane让平台与租户菜单不能接成一棵树复合外键约束子节点的父节点必须处于同一管理面。示例的CHECK约束依赖 MySQL 8.0.16 及以上版本更早的 8.0 小版本不会按此示例强制检查。根节点parent_key为空发布配置时还需校验无环、页面路由唯一、按钮挂在页面下不能期待外键自动发现循环或所有语义错误。页面和按钮的action_key是授权标识capability_key是可选的套餐能力标识。后者为空表示该节点不依赖特定付费能力不表示免除身份和权限检查。本例的菜单定义可压缩为节点管理面/类型页面或动作权限套餐能力租户列表平台/页面platform:tenant:view无暂停租户平台/按钮platform:tenant:suspend无成员列表租户/页面member:view无邀请成员租户/按钮member:invite无批量报表租户/页面report:viewbatch_report运行报表租户/按钮report:runbatch_report申请单租户/页面request:view无角色与动作的对应关系应从当前租户的有效成员身份解析平台动作来自平台人员身份。套餐能力则从目标租户当前有效权益解析。不要在demo_menu_catalog中为每家租户复制整套菜单再靠手工同步除非有真实的租户级菜单定制需求否则这会让菜单版本、撤权和回滚变得难以核对。角色授予范围与套餐订阅的具体表结构分别属于后续专题这里只使用解析后的动作集合与能力集合。缓存菜单时键至少区分执行面、账号、当前租户和授权版本例如menu:TENANT:u_07:t_a:memberV3:entV8:catalogV12。平台面不能沿用甲公司的键乙公司也不能拿到甲公司的结果。成员权限、权益或菜单定义变化时要使对应版本变更或主动失效即便前端暂时显示旧菜单后端接口仍按最新授权决定。这样菜单缓存是性能优化不是安全依据。图3全局菜单定义只描述入口不同租户的可见结果由身份、动作和权益共同计算。四、生成菜单先确定身份再求可见节点生成顺序可以固定为解析可信执行身份按管理面取菜单定义按动作与能力筛出页面和按钮仅保留可见页面下的按钮最后补齐可见页面的父分组。不要先把所有分组返给前端再让浏览器决定隐藏哪些子菜单客户端不应接收自己无权访问的完整平台菜单结构。下面是 Java 17 的简化投影逻辑。MenuDef从已校验的菜单目录加载VisibleContext由服务端当前身份及授权服务构造而非浏览器传入的角色列表enumPlane{PLATFORM,TENANT}enumKind{GROUP,PAGE,BUTTON}recordMenuDef(Stringkey,StringparentKey,Planeplane,Kindkind,StringactionKey,StringcapabilityKey){}recordVisibleContext(Planeplane,StringtenantId,SetStringactions,SetStringcapabilities){}finalclassMenuProjection{SetStringvisibleKeys(ListMenuDefcatalog,VisibleContextcontext){MapString,MenuDefbyKeycatalog.stream().collect(Collectors.toMap(MenuDef::key,Function.identity()));SetStringvisiblenewLinkedHashSet();for(MenuDefnode:catalog){if(node.plane()!context.plane()||node.kind()Kind.GROUP)continue;if(node.actionKey()null||!context.actions().contains(node.actionKey()))continue;if(node.capabilityKey()!null!context.capabilities().contains(node.capabilityKey()))continue;if(node.kind()Kind.BUTTON){MenuDefparentbyKey.get(node.parentKey());if(parentnull||parent.kind()!Kind.PAGE||!allowedPage(parent,context))continue;}visible.add(node.key());StringparentKeynode.parentKey();while(parentKey!null){MenuDefparentbyKey.get(parentKey);if(parentnull||parent.plane()!context.plane())thrownewIllegalStateException(BROKEN_MENU_TREE);visible.add(parent.key());parentKeyparent.parentKey();}}returnvisible;}privatebooleanallowedPage(MenuDefpage,VisibleContextcontext){returnpage.plane()context.plane()page.actionKey()!nullcontext.actions().contains(page.actionKey())(page.capabilityKey()null||context.capabilities().contains(page.capabilityKey()));}}以上代码假设目录发布时已经验证树无环且父子类型正确生产加载器必须做这项校验否则循环父节点会使while不能结束。按钮筛选只检查父页面是否允许不授予页面之外的额外能力。构造VisibleContext时如果租户成员状态、权益状态或版本无法确认应返回拒绝或空菜单不能把缺失值解释为“显示全部”。固定输入下预期输出可以写成p_01 / PLATFORM - [租户列表, 暂停租户] u_07 / TENANT / t_a - [成员列表, 邀请成员, 批量报表, 运行报表, 申请单] u_07 / TENANT / t_b - [申请单]实际响应还会包含分组、路由和排序这里只列出有业务含义的节点便于核对结果。u_07在甲公司有report:run但若甲公司权益到期报表页面和按钮都应从新生成的菜单里消失已有页面上的旧按钮也不能绕过接口。五、按钮和接口如何保持一致前端的“运行报表”按钮可见条件与后端动作条件要来自同一套动作标识和能力标识但不是共用前端返回的布尔值。服务端执行时应重新确认当前租户、当前成员动作权限、当前权益和请求资源归属。这里不展开套餐订阅到期、配额预留和后台任务执行的完整策略后续会单独讨论本篇只验证入口与接口两层不能相互替代。TransactionalpubliclongrequestBatchReport(TenantActoractor,ReportInputinput){if(!members.hasAction(actor.tenantId(),actor.accountId(),report:run))thrownewAccessDeniedException(ACTION_DENIED);if(!entitlements.hasActiveCapability(actor.tenantId(),batch_report))thrownewAccessDeniedException(CAPABILITY_NOT_ACTIVE);if(!requests.belongsToTenant(actor.tenantId(),input.requestId()))thrownewAccessDeniedException(RESOURCE_NOT_IN_TENANT);returnjobs.createForTenant(actor.tenantId(),actor.accountId(),input.requestId());}TenantActor必须由当前会话的有效租户成员身份产生若平台人员直接调用此租户业务接口不能给他临时构造一个默认租户。前面的检查也不能替代jobs.createForTenant的租户归属约束避免“授权检查了甲公司写任务时却用了请求体的乙公司编号”。如果任务随后异步执行后台阶段还要遵循上一篇的可信任务归属规则。直接调用接口的反例尤其值得测乙公司用户浏览器里没有批量报表按钮但手工发送POST /tenant/reports/run。当其缺动作权限时返回ACTION_DENIED若测试账号有旧的report:run动作但乙公司权益未开通应返回CAPABILITY_NOT_ACTIVE两种情况都不能创建任务。授权检查顺序可以按产品需要调整但审计中应保留准确的拒绝原因不把两种不同问题混为一谈。图4按钮消失只改善交互真正阻止直调、旧页面和伪造请求的是服务端校验。六、测试、SQL核对与上线验收菜单投影至少测身份切换和权限、套餐的交叉变化。以下固定测试目录包含平台与租户两棵树以及对应按钮避免把“父分组会自动补齐”留成口头约定实际项目可由测试数据库加载这些节点。ListMenuDefcatalogList.of(newMenuDef(platform_group,null,Plane.PLATFORM,Kind.GROUP,null,null),newMenuDef(platform_tenants,platform_group,Plane.PLATFORM,Kind.PAGE,platform:tenant:view,null),newMenuDef(platform_suspend,platform_tenants,Plane.PLATFORM,Kind.BUTTON,platform:tenant:suspend,null),newMenuDef(tenant_group,null,Plane.TENANT,Kind.GROUP,null,null),newMenuDef(member_page,tenant_group,Plane.TENANT,Kind.PAGE,member:view,null),newMenuDef(member_invite,member_page,Plane.TENANT,Kind.BUTTON,member:invite,null),newMenuDef(report_page,tenant_group,Plane.TENANT,Kind.PAGE,report:view,batch_report),newMenuDef(report_run,report_page,Plane.TENANT,Kind.BUTTON,report:run,batch_report),newMenuDef(request_list,tenant_group,Plane.TENANT,Kind.PAGE,request:view,null));MenuProjectionprojectionnewMenuProjection();用 JUnit 5 与 AssertJ 核对甲乙两家公司及套餐变化TestvoidsameAccountGetsDifferentMenusInTwoTenants(){varanewVisibleContext(Plane.TENANT,t_a,Set.of(member:view,member:invite,report:view,report:run,request:view),Set.of(batch_report));varbnewVisibleContext(Plane.TENANT,t_b,Set.of(request:view),Set.of());assertThat(projection.visibleKeys(catalog,a)).contains(member_invite,report_run);assertThat(projection.visibleKeys(catalog,b)).contains(request_list).doesNotContain(member_invite,report_page,report_run,platform_tenants);}TestvoidentitlementLossHidesReportEvenWhenActionRemains(){varnoPackagenewVisibleContext(Plane.TENANT,t_a,Set.of(report:view,report:run),Set.of());assertThat(projection.visibleKeys(catalog,noPackage)).doesNotContain(report_page,report_run);}TestvoidplatformMenuDoesNotInheritTenantPages(){varplatformnewVisibleContext(Plane.PLATFORM,null,Set.of(platform:tenant:view,platform:tenant:suspend),Set.of());assertThat(projection.visibleKeys(catalog,platform)).contains(platform_group,platform_tenants,platform_suspend).doesNotContain(tenant_group,member_page,request_list);}菜单单测也不足以证明直调接口被拒绝至少还要测试乙公司无权限、旧权限但无套餐、平台身份直调租户接口、甲公司权限撤销后旧页面继续请求这四条路径每条拒绝路径都要断言任务表没有新行。SQL 核对建议关注目录本身是否混树与租户权益是否被误写成全局菜单开关。下面第一条预期0 行第二条帮助核对本例涉及的节点配置第三条由项目实际权益表查询预期甲、乙结果不同不能因为菜单定义相同就返回相同能力-- 跨管理面的父子关系应为 0 行复合外键也会阻止新的错误写入。SELECTchild.menu_key,child.plane,parent.menu_key,parent.planeFROMdemo_menu_catalog childJOINdemo_menu_catalog parentONparent.menu_keychild.parent_keyWHEREchild.planeparent.plane;-- 本例的稳定动作标识与能力标识应与接口使用的一致。SELECTmenu_key,plane,node_kind,parent_key,action_key,capability_keyFROMdemo_menu_catalogWHEREmenu_keyIN(platform_tenants,member_invite,report_page,report_run,request_list)ORDERBYplane,menu_key;-- 权益需按 tenant_id 核对示意结果t_a 开通 batch_reportt_b 未开通。-- 具体表名和生效时间规则由套餐专题定义不在菜单目录里写死。上线验收时可把结果固定成一张交叉表场景菜单预期接口预期证据平台p_01查看导航只有平台相关入口租户业务接口拒绝菜单响应、接口日志u_07进入甲公司成员、报表、申请单甲公司授权动作可执行当前租户、任务归属u_07切到乙公司仅申请单邀请和报表直调拒绝新菜单、零副作用甲公司报表权益失效报表入口消失旧页面直调也拒绝权益版本、任务表甲公司成员权限撤销邀请按钮消失旧页面邀请也拒绝成员版本、成员表菜单缓存仍有旧结果页面可能短暂显示旧入口服务端始终按当前事实拒绝缓存键、授权日志菜单生成可以缓存但不能把缓存时间当成权限生效时间。高风险动作若要求撤权即时生效应在接口内读取足够新的授权事实并在写入前核对当前状态。平台与租户的菜单目录、角色动作和套餐权益版本若无法确定默认不显示受保护入口接口则默认拒绝而不是在故障时回退为“全部允许”。本篇的核心判断是**菜单是当前权限和权益的可见投影不是授权凭证。**先确认执行的是平台身份还是某租户成员身份再计算页面和按钮真正执行时重新检查动作、能力与资源归属。这样同一个人切换公司时导航会正确变化旧按钮和直调接口也不会绕过租户边界。图5验收要同时观察菜单响应和接口副作用权限、套餐变化后也要重测。参考资料OWASPAuthorization Cheat SheetSpring SecurityHTTP 请求授权与方法授权AWS Prescriptive Guidance多租户 SaaS 授权与 API 访问控制MySQL 8.0CHECK 约束版本说明

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑