ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

平台管理员和租户管理员权限怎么分?用管理对象与接口边界避免越权

平台管理员和租户管理员权限怎么分?用管理对象与接口边界避免越权

多租户系统里,“管理员”不是一个能从上往下继承所有权限的等级。平台人员可以管理租户开通和停用,却不一定能看租户的业务单据;租户管理员可以维护本公司的成员,却不能修改另一家公司,也不能调整平台的套餐模板。把两类管理员塞进同一个ADMIN角色,短期省事,后续最容易出现“平台支持人员直接查看客户数据”或“租户管理员猜接口修改其他租户”的问题。

本文解决的是管理对象与操作责任的分界,不重复介绍一般的 RBAC 菜单配置。示例环境为 Java 17、Spring Boot 风格服务层和 MySQL 8.x;账号、公司与操作均为虚构。角色如何授予及防止管理员给自己提权,留到本系列后续专题;这里先把谁能进入哪个管理面、能对哪个对象做什么说清楚。

目录

  1. 一组账号,三个不同的管理动作
  2. 先按对象划分管理面,再谈角色名称
  3. 身份模型与接口如何表达这条边界
  4. 服务端授权与数据写入必须同时约束
  5. 正常、越权和状态变化怎么测试
  6. 上线核对与例外访问的处理边界

一、一组账号,三个不同的管理动作

假设平台服务甲公司t_a和乙公司t_b。平台运营人员p_01负责租户生命周期;账号u_07是甲公司的租户管理员,同时只是乙公司的普通查看者;乙公司的管理员是u_21。现在发生三件事:

  1. p_01因合同到期暂停乙公司的新业务操作,目标对象是租户t_b的运行状态。
  2. u_07在甲公司邀请新成员u_30,目标对象是甲公司的成员关系。
  3. 有人把第 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:验收同时检查允许的写入和被拒绝请求的零副作用,不只看页面提示。

参考资料:

  1. OWASP:Authorization Cheat Sheet
  2. AWS Prescriptive Guidance:多租户 SaaS 授权与 API 访问控制
  3. AWS SaaS Lens:身份与租户上下文
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进