多租户系统里,“管理员”不是一个能从上往下继承所有权限的等级。平台人员可以管理租户开通和停用,却不一定能看租户的业务单据;租户管理员可以维护本公司的成员,却不能修改另一家公司,也不能调整平台的套餐模板。把两类管理员塞进同一个ADMIN角色,短期省事,后续最容易出现“平台支持人员直接查看客户数据”或“租户管理员猜接口修改其他租户”的问题。
本文解决的是管理对象与操作责任的分界,不重复介绍一般的 RBAC 菜单配置。示例环境为 Java 17、Spring Boot 风格服务层和 MySQL 8.x;账号、公司与操作均为虚构。角色如何授予及防止管理员给自己提权,留到本系列后续专题;这里先把谁能进入哪个管理面、能对哪个对象做什么说清楚。
目录
- 一组账号,三个不同的管理动作
- 先按对象划分管理面,再谈角色名称
- 身份模型与接口如何表达这条边界
- 服务端授权与数据写入必须同时约束
- 正常、越权和状态变化怎么测试
- 上线核对与例外访问的处理边界
一、一组账号,三个不同的管理动作
假设平台服务甲公司t_a和乙公司t_b。平台运营人员p_01负责租户生命周期;账号u_07是甲公司的租户管理员,同时只是乙公司的普通查看者;乙公司的管理员是u_21。现在发生三件事:
p_01因合同到期暂停乙公司的新业务操作,目标对象是租户t_b的运行状态。u_07在甲公司邀请新成员u_30,目标对象是甲公司的成员关系。- 有人把第 2 步请求里的
tenantId改成t_b,希望借甲公司的管理员身份修改乙公司的成员。
预期结果并不是“平台人员全能,管理员次之”。第 1 步只允许持有平台租户状态管理权限的人员执行;第 2 步只允许甲公司有效管理员在甲公司范围内执行;第 3 步必须拒绝。即使p_01能暂停乙公司,也不因此获得乙公司申请单的读取权限。
| 操作者与当前身份 | 操作对象 | 预期 | 原因 |
|---|---|---|---|
p_01,平台运营身份 | 暂停t_b新业务操作 | 允许 | 授权作用于平台管理的租户状态对象 |
p_01,平台运营身份 | 读取t_b的业务申请单 | 拒绝 | 平台运营权限不等于租户业务数据权限 |
u_07,甲公司管理员身份 | 邀请u_30加入t_a | 允许 | 当前租户和目标成员关系均为t_a |
u_07,甲公司管理员身份 | 修改t_b的成员 | 拒绝 | 改 URL 不会改变已确认的管理范围 |
u_07,乙公司查看者身份 | 邀请u_30加入t_b | 拒绝 | 同一账号在不同租户可以有不同角色 |
图1:用同一组账号同时验证允许路径和拒绝路径,才能看清“管理员”二字背后的不同授权来源。
二、先按对象划分管理面,再谈角色名称
权限设计先回答“谁拥有这个对象”,再回答“谁能执行这个动作”。一种可审查的划分是:
| 管理面 | 对象示例 | 典型动作 | 不自动包含的权限 |
|---|---|---|---|
| 平台管理面 | 租户目录、开通状态、套餐模板、全局策略 | 创建租户、暂停/恢复服务、维护模板 | 读取或修改某租户的业务单据 |
| 租户管理面 | 本租户成员、组织结构、租户内配置 | 邀请成员、停用成员、维护本租户设置 | 管理其他租户、维护平台模板 |
| 业务操作面 | 申请单、资产、附件、报表等业务资源 | 查询、审批、导出 | 因为能管理租户而获得全部业务数据 |
这里的“管理面”不是要求部署三套系统。一个服务也可以通过不同的路由、主体类型和服务接口把边界表达清楚;反过来,即使拆成三个微服务,只要共享一个不受约束的isAdmin(),越权仍然存在。
平台管理员有时需要处理客户故障,但支持访问不是平台权限的默认延伸。查看客户业务数据应另有明确的目标租户、操作范围、时效与审计,不能把平台人员临时加入所有租户的管理员列表。本系列会单独讨论这种例外访问;本文的正常路径一律拒绝平台人员直接进入租户业务接口。
套餐也不是管理员等级。平台可以维护套餐模板,租户管理员可以查看自己租户当前开通的权益,却不能通过改一条本租户配置自行开通未购买能力。菜单的可见性、按钮权限和套餐能力会在下一篇拆开;本篇只把管理责任放在正确的对象上。
图2:平台状态、租户成员和业务资源分属不同管理面;某一面的管理员不自动成为另一面的超级用户。
三、身份模型与接口如何表达这条边界
不建议在账号表上只放一个全局role=ADMIN。账号可以同时具备平台职位与某租户的成员身份,但每次请求只能用经过确认的一个执行身份。下面只保留本案例所需的最小结构:
-- 第 04 篇 demo_tenant 只保留了主键;本篇增加运行状态作示意。ALTERTABLEdemo_tenantADDCOLUMNtenant_statusVARCHAR(20)NOTNULLDEFAULT'ACTIVE';CREATETABLEdemo_platform_operator(account_idVARCHAR(32)PRIMARYKEY,statusVARCHAR(20)NOTNULL,can_manage_tenant_stateBOOLEANNOTNULLDEFAULTFALSE);CREATETABLEdemo_tenant_member(tenant_idVARCHAR(32)NOTNULL,account_idVARCHAR(32)NOTNULL,member_statusVARCHAR(20)NOTNULL,can_manage_membersBOOLEANNOTNULLDEFAULTFALSE,member_versionBIGINTNOTNULLDEFAULT1,PRIMARYKEY(tenant_id,account_id),CONSTRAINTfk_member_tenantFOREIGNKEY(tenant_id)REFERENCESdemo_tenant(tenant_id));-- demo_tenant 的状态列用来控制新操作;生产迁移需核对已有租户的真实状态。这里用两个布尔能力位说明边界,不代表生产系统只能使用布尔字段。实际可以接现有角色/策略表,但要保留两个事实:平台授权不靠租户成员记录推导;租户管理员权限必须绑定具体tenant_id。member_version供角色变更后的会话或缓存失效检查,不能让旧授权无限期可用。
接口也应让目标对象一目了然。例如:
| 接口 | 主体来源 | 服务端必须校验 |
|---|---|---|
POST /platform/tenants/t_b/suspend | 平台管理身份 | 平台人员有效、具备状态管理动作、目标租户存在且状态可转换 |
POST /tenant/members/invite | 已确认的当前租户身份 | 当前成员有效、具备本租户成员管理动作、邀请目标归属当前租户 |
GET /tenant/requests/1001 | 已确认的当前租户身份 | 资源属于当前租户,并满足业务权限与数据范围 |
/platform/tenants/t_b/suspend中的t_b是目标资源,不是调用者自行声明的平台身份。/tenant/members/invite即使请求体包含tenantId,也只能拿它和可信当前租户比较;不一致直接拒绝。前端隐藏菜单可以减少误点,但不能替代服务端校验。
一些系统允许同一账号兼任平台人员和租户成员。兼任不意味着两种权限可以在一次请求里取并集:访问平台管理接口时使用平台身份,访问租户管理接口时使用已确认的租户成员身份。缺哪一种身份,就拒绝该类操作。这样日志里也能解释“他当时是以什么身份做了什么”。
图3:路由只是入口,真正的授权发生在服务端对主体、动作和目标对象的核对中。
四、服务端授权与数据写入必须同时约束
服务层不能只做if (principal.isAdmin())。平台状态操作要明确平台身份和状态迁移;租户邀请要明确当前租户、成员能力和写入归属。下面的 Java 17 代码展示两个独立入口,异常类与仓储接口可由项目框架实现:
recordPlatformActor(StringaccountId){}recordTenantActor(StringaccountId,StringtenantId,longmemberVersion){}recordInviteCommand(StringaccountId,StringrequestedTenantId){}finalclassManagementService{privatefinalPlatformOperatorsoperators;privatefinalTenantMembersmembers;privatefinalTenantstenants;ManagementService(PlatformOperatorsoperators,TenantMembersmembers,Tenantstenants){this.operators=operators;this.members=members;this.tenants=tenants;}@TransactionalpublicvoidsuspendTenant(PlatformActoractor,StringtargetTenantId){if(!operators.canManageTenantState(actor.accountId()))thrownewAccessDeniedException("PLATFORM_ACTION_DENIED");if(tenants.suspendIfActive(targetTenantId)!=1)thrownewIllegalStateException("TENANT_STATE_CHANGED");}@TransactionalpublicvoidinviteMember(TenantActoractor,InviteCommandcommand){if(!actor.tenantId().equals(command.requestedTenantId()))thrownewAccessDeniedException("TENANT_TARGET_MISMATCH");if(!tenants.isActive(actor.tenantId()))thrownewAccessDeniedException("TENANT_NOT_ACTIVE");if(!members.canManageMembers(actor.tenantId(),actor.accountId(),actor.memberVersion()))thrownewAccessDeniedException("TENANT_ACTION_DENIED");members.insertPendingInvite(actor.tenantId(),command.accountId());}}PlatformActor和TenantActor必须由认证及可信上下文解析层构造,不允许控制器直接用请求体accountId、tenantId组装。实际项目也可以用一个主体类型加明确的执行模式,但不要提供“平台管理员自动通过租户接口”的兜底分支。
代码里operators.canManageTenantState必须同时确认平台人员状态有效及动作权限;members.canManageMembers查询的是当前租户的有效成员关系及版本,不是账号的全局属性。邀请写入时再次带上可信actor.tenantId(),避免授权检查正确、落库却使用请求体租户字段的“最后一米”错误。insertPendingInvite应利用(tenant_id, account_id)唯一键处理重复邀请;提交新邀请后是否立刻开通,要由邀请确认流程决定,不能在这里默默把目标账号升级为管理员。
对平台状态操作,数据库更新也应带状态条件:
UPDATEdemo_tenantSETtenant_status='SUSPENDED'WHEREtenant_id=:targetTenantIdANDtenant_status='ACTIVE';-- 预期:第一次暂停 t_b 影响 1 行;重复暂停影响 0 行,按业务返回已暂停或状态冲突。暂停租户对后台任务、登录和历史数据各有什么影响,是生命周期策略,不能靠这一条 SQL 推断全部结果。平台动作应记录操作者、目标租户、动作、结果与时间;租户成员动作则还应记录执行时的当前租户。审计记录帮助追责,但不能反过来替代权限校验。
图4:授权判断和实际写入都要落在同一个目标范围内,不能检查甲租户却写入乙租户。
五、正常、越权和状态变化怎么测试
固定案例的核心测试不是“管理员能打开菜单”,而是同一账号切换执行身份后,目标对象会不会跟着越界。下面用 JUnit 5 与 Mockito 风格示意关键断言;示例假定已按前述接口注入service、operators、members、tenants。
@TestvoidplatformOperatorCanSuspendWithoutTenantMemberLookup(){when(operators.canManageTenantState("p_01")).thenReturn(true);when(tenants.suspendIfActive("t_b")).thenReturn(1);service.suspendTenant(newPlatformActor("p_01"),"t_b");verify(tenants).suspendIfActive("t_b");verifyNoInteractions(members);}@TestvoidtenantAdminCannotInviteIntoAnotherTenantByChangingBody(){TenantActoractor=newTenantActor("u_07","t_a",3);assertThatThrownBy(()->service.inviteMember(actor,newInviteCommand("u_30","t_b"))).hasMessage("TENANT_TARGET_MISMATCH");verifyNoInteractions(members);}@TestvoidsameAccountAsViewerInOtherTenantCannotReuseAdminPermission(){TenantActoractor=newTenantActor("u_07","t_b",1);when(tenants.isActive("t_b")).thenReturn(true);when(members.canManageMembers("t_b","u_07",1)).thenReturn(false);assertThatThrownBy(()->service.inviteMember(actor,newInviteCommand("u_30","t_b"))).hasMessage("TENANT_ACTION_DENIED");verify(members,never()).insertPendingInvite(anyString(),anyString());}还要补三类集成测试:一是用真实认证入口证明不能伪造PlatformActor;二是直接调用接口而不是依赖菜单隐藏,包括验证平台人员读取租户业务单据被拒绝;三是角色撤销、租户暂停、多标签页旧会话等状态变化。单测可以证明服务方法按参数拒绝,但不能证明路由过滤器、会话解析和数据库更新条件都正确。对于撤权后必须立即停止的高风险动作,授权查询与写入还要采用可解释的事务内重查或锁定策略,不能只信请求开始时的缓存结果。
数据库侧可以核对最小事实:
-- u_07 在甲、乙租户的成员能力应不同;预期两行。SELECTtenant_id,account_id,member_status,can_manage_members,member_versionFROMdemo_tenant_memberWHEREaccount_id='u_07'ORDERBYtenant_id;-- 邀请 u_30 后,只应在甲租户出现待确认成员;乙租户预期 0 行。SELECTtenant_id,account_id,member_statusFROMdemo_tenant_memberWHEREaccount_id='u_30'ANDtenant_idIN('t_a','t_b');-- p_01 是否确有平台状态管理授权;预期一行且状态有效。SELECTaccount_id,status,can_manage_tenant_stateFROMdemo_platform_operatorWHEREaccount_id='p_01';SQL 结果不能单独证明接口没有越权,必须与 HTTP 结果、服务端授权日志和审计记录一起看。尤其要验证被拒绝的跨租户邀请没有生成成员、邀请消息或后台任务;不能出现接口返回 403,但异步副作用已经发生。
六、上线核对与例外访问的处理边界
上线时至少做下面六次交叉验证,包含合法、拒绝和状态变化:
| 操作 | 预期结果 | 证据位置 |
|---|---|---|
p_01暂停t_b | 目标状态改变一次,有审计记录 | 租户状态、影响行数、操作日志 |
p_01直接读取t_b业务单据 | 拒绝,无业务数据返回 | HTTP 响应、业务查询日志 |
u_07在t_a邀请u_30 | 仅产生甲租户待确认成员 | 成员表(tenant_id, account_id) |
u_07把请求体租户改为t_b | 拒绝,无邀请副作用 | 接口结果、成员表、消息记录 |
u_07切到t_b查看者身份后邀请 | 拒绝,不借用甲租户管理员权限 | 当前身份、授权日志 |
撤销u_07的甲租户管理能力后重试 | 旧上下文不能继续邀请 | 成员版本、接口结果 |
如果平台确实需要协助客户处理业务数据,要把它设计成例外流程:明确目标租户、许可动作、有效时间和审计责任,再由受控入口建立临时执行身份。不要给平台人员一个永久的“读取所有租户数据”总开关,也不要把支持人员静默写成租户管理员。具体临时访问机制和留痕会在后续专题展开。
本文的边界可以概括为一句话:**平台管理员管理平台对象,租户管理员管理本租户对象,业务数据另按当前租户与业务权限授权。**名称相同的“管理”不是权限继承关系。先让主体、动作和对象对得上,再考虑菜单如何展示,系统才不会因为一个笼统的ADMIN把各家公司的责任混在一起。
图5:验收同时检查允许的写入和被拒绝请求的零副作用,不只看页面提示。
参考资料:
- OWASP:Authorization Cheat Sheet
- AWS Prescriptive Guidance:多租户 SaaS 授权与 API 访问控制
- AWS SaaS Lens:身份与租户上下文