多租户设备管理平台最危险的权限错误,通常不是“忘了给管理员增加一个菜单权限”,而是系统把“这个人具备 command:write”误解成“这个人可以操作当前查询到的所有设备”。RBAC 只能回答主体是否具备某类动作能力;它不能单独回答请求属于哪个租户、目标设备位于哪个站点、是否归属同一服务方、批量操作会扩散到多大范围,以及这次高风险授权是否已经过期。
因此,可靠的模型应把一次访问决策写成连续判定:主体身份可信,目标租户匹配,动作权限允许,资源范围相交,所有者边界一致,当前上下文仍然有效。任意一层不满足,请求都应默认拒绝。这个结论同时适用于页面查询、API、服务账号、远程命令、OTA 和批量运维;差别只在于每类操作的执法点与审计强度。
本文基于 Grus IoT Core 当前授权实现和 13 项定向测试展开。测试证明了代码中跨租户、越权动作、跨 scope、跨 service owner 与跨租户命令的拒绝路径,但它不是生产渗透测试,也不证明策略缓存、数据库并发或 MQTT topic ACL 已在任意规模下达标。
1. 先把权限问题写成一个完整请求
设备平台里的访问请求至少包含六个对象:subject、tenant、action、resource、scope 和 context。如果权限表里只有“角色—菜单—按钮”,系统只是控制了界面入口,没有定义资源边界。
subject是用户、服务账号或集成适配器,不只是一个用户名。tenant是当前请求被允许进入的客户边界,不能由普通调用方随意切换。action是device:read、command:write、ota:write等可审计动词。resource是具体设备、设备组、站点、告警、命令或升级任务。scope描述主体可见的项目、站点、空间树或设备组范围。context包含 service owner、访问渠道、授权期限、工单、风险等级与 trace id。
RBAC 仍然重要,因为动作能力必须集中治理;问题在于不能把它当作全部答案。一个供应商角色可能拥有 device:read 和 command:write,但它只应作用于该供应商负责的设备,并且仍要受租户和站点范围限制。若系统先按角色放行,再由业务代码“顺便”过滤设备,任何漏掉过滤的接口都会成为跨客户数据泄漏点。
更稳妥的做法是让 API 入口只负责取得可信身份和目标对象,把完整决策输入交给统一授权层。业务服务不应自己猜测“管理员大概可以”,数据仓储也不应接收一个没有租户条件的全表查询后再在内存中过滤。
2. 五层判定链必须在每个资源入口闭合
下面的判定链不是五套互相独立的权限系统,而是一次请求的五道连续约束。角色权限位于其中一层,资源边界与运维上下文由其他层补齐。
flowchart LR
A("身份与可信租户"):::blue --> B("动作权限"):::cyan
B --> C("站点 / 设备组 scope"):::orange
C --> D("service owner 边界"):::violet
D --> E("期限、工单与风险上下文"):::slate
E --> F("允许并写入审计"):::green
A -. 不匹配 .-> X("默认拒绝"):::deny
B -. 不允许 .-> X
C -. 不相交 .-> X
D -. 不一致 .-> X
E -. 已过期或缺少依据 .-> X
classDef blue fill:#EAF4FF,stroke:#3B82F6,color:#16324F,stroke-width:2px;
classDef cyan fill:#E9FBF8,stroke:#14B8A6,color:#134E4A,stroke-width:2px;
classDef orange fill:#FFF3E8,stroke:#F08A24,color:#7C3F00,stroke-width:2px;
classDef violet fill:#F4EDFF,stroke:#8B5CF6,color:#4C1D95,stroke-width:2px;
classDef green fill:#ECFDF3,stroke:#22C55E,color:#14532D,stroke-width:2px;
classDef slate fill:#F8FAFC,stroke:#64748B,color:#1F2937,stroke-width:2px;
classDef deny fill:#FFF1F2,stroke:#E11D48,color:#881337,stroke-width:2px;
第一层是身份租户。Token 里的 tenant_id 不应自动成为可切换租户的通行证。Grus IoT Core 的 OIDC 路径先解析不可变身份绑定,再从数据库读取有效角色和 scope;只有受信任的 platform_admin 类角色才允许选择另一个租户上下文。定向测试同时验证了 tenant_admin 无法跨租户,以及普通 JWT 中自称 platform_admin 不能在缺少可信角色来源时获得跨租户权限。
第二层是动作权限。权限名应使用资源与动词组成的稳定契约,例如 device:read、command:write、audit:read。把“可看设备列表”和“可发送命令”拆开后,运维观察员可以诊断问题而不具备物理控制权。权限粒度也不应细到每个按钮一个字符串,否则策略难以审计,角色差异会退化为大量无法解释的例外。
第三层是资源 scope。租户内部通常仍有项目、站点、产线、房间或设备组边界。同租户不等于全租户可见。一个站点工程师可以读取自己站点的设备和告警,但不应因为拥有 device:read 就看到兄弟站点。Scope 应在查询条件和对象读取入口都执行,而不是只在前端树形菜单中隐藏节点。
第四层是 service owner。设备可能由安装商、渠道商、运维服务商或集成适配器代管。它与租户不是同一概念:租户表示客户隔离,owner 表示同一客户内部谁对一组资源承担服务责任。Grus 的授权实现对 installer、vendor 与 service_adapter 等服务主体要求 owner claim,并在设备、命令、告警、审计、OTA 和报表等动作上比较资源 owner。没有 owner claim 或 owner 不一致时,系统默认拒绝。
第五层是运维上下文。高风险动作不能只凭长期角色永久开放。批量重启、固件升级、密钥轮换和远程控制至少需要授权期限、工单或变更单、目标快照、原因、审批来源与 trace id。上下文不是“审批系统做完后给 API 一个备注”,而是授权判定与审计记录的一部分。
3. 列表、对象读取和命令写入不能共用一种执法方式
权限模型经常在单对象接口上看起来正确,却在列表和批量操作上泄漏。原因是三类入口的失败模式不同。
列表查询必须在数据源处收窄。正确条件至少包含 tenant_id,并根据主体增加 scope 与 owner 约束。先查出全租户甚至全表数据,再由 API 层逐行删除不该看的记录,会放大内存、分页、计数和缓存泄漏风险。尤其是总数、聚合、导出和搜索建议,即使不返回设备详情,也可能暴露另一个站点的设备数量、名称或异常趋势。
单对象读取需要“先定位、再授权、再序列化”。如果仓储用全局 device_id 取出对象,API 必须在输出任何字段前比较租户、scope 和 owner。更好的仓储契约是直接要求 tenant_id + device_id,让错误请求在数据边界返回不可见。错误信息也应避免向无权主体区分“设备不存在”和“设备存在但你无权访问”,否则会形成资源枚举侧信道。
命令写入比读取更严格,因为它会产生物理副作用。除了 command:write,系统还应验证设备当前归属、scope、控制能力、目标版本、幂等键、命令模板、风险等级和审计上下文。一个请求在创建时被允许,不代表重试时仍然允许;延迟队列、重试 worker 和协议适配器必须携带并重新验证不可伪造的租户与 owner 事实,不能只信任调用方传入的 topic 或 device id。
批量操作则要防止“单个目标可操作,所以整个查询结果都可操作”的推论。平台应先冻结目标快照,逐个做授权与兼容检查,再生成可审计的允许集和拒绝集。若产品选择 fail-all,任何一个目标不合格就终止整批;若选择 partial success,必须把每个目标的决策和后果分别记录,不能只留一条“批量任务成功 93%”。

运维交接时,最值得核对的不是“新同事属于哪个角色”,而是他在什么时间、通过什么渠道、为了哪张工单、能操作哪些站点和设备组,以及权限到期后由谁确认撤销。把这些问题留在聊天记录里,系统就无法在命令执行时做同样判断。
4. Threat Model:五条最容易被忽略的越权路径
| 攻击或错误路径 | 直接触发 | 更深层原因 | 正确控制 |
|---|---|---|---|
| 跨租户切换 | 修改 X-Tenant-Id |
把请求头当成可信身份事实 | 身份绑定先于租户选择;只有受信平台角色可切换 |
| 同租户跨站点读取 | 保留 device:read,替换 site/device id |
RBAC 没有资源 scope | 查询与对象入口都执行 scope 交集 |
| 服务方横向访问 | vendor A 请求 vendor B 的设备 | 租户隔离代替了 owner 隔离 | 服务主体强制 owner claim 并匹配资源 owner |
| 低权限变高风险动作 | 从列表页面构造命令 API | 页面隐藏被误当授权 | API 独立检查 command:write、目标与上下文 |
| 批量扇出越界 | 用可见查询拼出包含不可见设备的目标集 | 批处理只检查任务创建者 | 冻结目标、逐项授权、记录允许集与拒绝集 |
这些路径的共同根因不是“少写了一个 if”,而是没有把授权定义为资源请求的不变量。若每个模块各写一套 tenant_id == current_tenant,新接口、后台任务和缓存层迟早会出现语义漂移。授权输入和拒绝原因应形成稳定契约,API、服务、worker 与消息消费者都使用同一套判定语义。
Grus 当前定向测试覆盖了四类关键拒绝:租户不一致、permission 不允许、scope 不匹配和 service owner 不匹配;另有跨租户命令、跨 owner 命令测试。13 项测试全部通过,证明这些具体代码路径按预期 fail closed。它们没有覆盖 MQTT broker topic ACL,因此不能据此宣称数据面已经完成端到端隔离;设备凭证、topic 模板和 broker ACL 仍需单独验证。
5. Controls:角色、scope 与临时授权应该怎样分工
角色适合表达稳定职责。例如 tenant_admin 维护租户配置,operator 处理日常运维,installer 完成安装交付,vendor 处理其负责设备,rule_auditor 只读规则与审计。角色数量应保持可解释;如果每个客户都新增十几个近似角色,实际问题往往是 scope、owner 或临时授权没有被建模。
Scope 适合表达资源集合。站点树、空间树、项目和设备组可以映射为 scope,但要明确继承规则。父站点授权是否自动包含子区域、设备移动后旧授权何时失效、动态设备组按标签变化时谁承担风险,都必须有确定答案。对于安全敏感操作,动态查询组不应在执行时无限扩张,最好在审批时冻结成员或记录可复现的筛选版本。
Owner 适合表达服务责任。它不是第二个 tenant,也不应由用户自行填写。Owner 通常来自设备绑定、集成来源或服务合同,并由受控流程变更。设备转交服务商时,应同时处理未完成命令、告警订阅、OTA 任务、API 凭据和历史审计可见性;只修改设备表一个字段会留下半迁移状态。
临时授权适合高风险和短期任务。一个可执行的临时授权至少包含:申请人、批准人、动作集合、资源快照、开始与结束时间、用途、工单、撤销状态和审计 trace。到期应在判定层自动失效,而不是等待管理员第二天手工删角色。紧急 break-glass 权限可以跳过常规审批时长,但不能跳过强认证、最小范围、短期限、实时告警和事后复核。
访问渠道也应进入控制面。Web 控制台、Open API、设备数据面和内部 worker 的风险不同。用户有 Web 登录权不等于自动获得 Open API;服务账号具备 API 权限也不应进入管理界面。将 channel 作为有效访问的一部分,可以避免“一次授权打开所有入口”。
6. Verification:不要只测试允许路径
权限测试至少要让每个维度都有一个“允许”和一个“拒绝”样例,并验证拒绝发生在敏感数据离开边界之前。下面的矩阵比“管理员能看到设备页”更接近真实验收。
| 维度 | 允许样例 | 必须拒绝的样例 | 需要检查的副作用 |
|---|---|---|---|
| 租户 | 平台管理员以可信角色进入目标租户 | tenant admin 修改请求头跨租户 | 无对象详情、无审计泄漏、无缓存污染 |
| 动作 | operator 读取设备 | tenant user 修改角色配置 | 无写入、无任务创建 |
| Scope | 站点 A 工程师读取 A 的设备 | 同主体读取站点 B | 列表、count、导出与搜索均不泄漏 |
| Owner | vendor A 操作其负责设备 | vendor A 操作 vendor B 设备 | 无命令、无通知、无重试记录 |
| Context | 有效工单内执行单设备诊断 | 过期授权发起批量重启 | 目标快照不创建或明确终止 |
测试还应覆盖身份系统或权限数据库不可用时的行为。生产系统不能在有效角色解析失败时退回 Token 自带角色,也不能为了“保持运维可用”把 scope 置为 *。对写操作和跨租户入口,失败时拒绝通常比旧缓存放行更安全;如果业务必须使用短时缓存,需要定义最大陈旧时间、撤销传播上限、缓存签名与紧急失效机制。
审计验证不能只检查“有日志”。每条高风险操作至少要关联主体、有效租户、目标资源、permission、scope/owner 决策、策略版本、请求来源、trace id、结果和失败原因。审计查询本身也受租户、scope 和 owner 限制,否则安全日志会成为跨租户信息最完整的泄漏入口。
7. Remediation Plan:从只有 RBAC 的平台迁移
第一阶段先建立资源事实,不急着增加角色。给设备、站点、设备组、命令、告警、OTA 任务和审计记录补齐不可变租户字段;明确 service owner 与资源 scope 的来源和变更流程。此时可以先记录影子决策,不改变线上结果,用来发现旧接口缺少哪些事实。
第二阶段统一动作字典和授权输入。把“菜单权限”“接口权限”“后台任务权限”收敛为稳定的资源动作,并让所有资源入口调用同一授权契约。列表查询在仓储层加入 tenant/scope/owner 条件,单对象和写操作在服务入口再次校验。迁移期间不要把旧角色直接映射成 *,而应根据真实职责生成最小动作集。
第三阶段先阻断跨租户和跨 owner,再逐步收紧 scope。跨租户泄漏的爆炸半径最大,应最先 fail closed;服务方横向访问紧随其后。Scope 迁移可能影响现有站点运维,需要先通过影子日志比较新旧结果,再对差异做授权修复,而不是把所有差异都当作误报。
第四阶段把远程命令、OTA、密钥和批量任务升级为上下文授权。引入有效期、工单、目标快照、幂等键和 break-glass 流程,并让 worker 在执行前验证授权仍然有效。最后再做持续验证:每次新增资源类型、查询、导出、聚合或消息消费者,都必须附带越权测试和审计断言。
如果团队规模很小、所有设备确实属于同一客户且没有外部服务商,完整 owner 与临时授权系统可能暂时过重。但租户字段、动作权限、资源 scope 和审计契约仍应从第一天保留扩展位。等第二个客户、供应商或站点出现后再重构全链路,代价通常高于早期把授权输入写完整。
8. 结论
多租户设备管理的权限模型不应被简化为角色表。RBAC 负责稳定动作能力,租户负责客户隔离,scope 负责站点和设备集合,service owner 负责服务方责任边界,上下文负责时间、工单与风险条件;统一判定链负责把它们组合成一次可解释的允许或拒绝。
真正的完成标准也不是“管理员能用、普通用户看不到按钮”,而是跨租户、跨 scope、跨 owner、越权动作和批量扇出都有可复现的拒绝证据,且拒绝不会留下命令、任务、通知或缓存副作用。只要这些边界还分散在前端、API 和后台任务的临时判断里,平台就仍然依赖开发者记忆,而不是依赖可验证的安全契约。
FAQ
ABAC 能不能直接替代 RBAC?
不建议把问题理解成替代。RBAC 适合稳定职责,属性和关系适合租户、scope、owner、时间与工单等动态约束。多数设备平台需要组合,而不是只选一种缩写。
平台管理员是否应该能跨所有租户?
可以存在跨租户平台角色,但它必须来自可信身份与权限数据源,并对租户切换、敏感读取和写操作保留更强审计。普通 Token 里的自报角色不能获得这项能力。
MQTT topic ACL 是否可以替代 API 授权?
不能。Broker ACL 保护消息数据面,API 授权保护控制面和业务资源。两者的 tenant、device 与 owner 语义应一致,但执法点不同,必须分别测试。