跳到主要内容

用 Cloudbase 数据库安全规则做多租户隔离

一句话定义:在 users 集合里给每个用户记 tenantIdrole,业务集合的安全规则用 get('database.users.${auth.openid}') 跨集合拿当前用户的租户身份,实现「同一租户内可见 + 不同 role 可写程度不同」,云函数侧再做一道兜底,防止规则写漏。

预计耗时:45 分钟 | 难度:进阶

适用场景

  • 适用:SaaS 类小程序 / Web 应用,一个 Cloudbase 环境承载多个客户公司
  • 适用:同一个用户在不同租户里有不同角色(owner / admin / member)
  • 不适用:每个客户独占一个 Cloudbase 环境(那是物理隔离,本篇是逻辑隔离)
  • 不适用:对租户数据有强合规审计要求的金融场景。规则只能拦住前端 SDK 调用,不能替代审计 / 加密

环境要求

依赖版本
@cloudbase/js-sdk2.27.3
@cloudbase/node-sdk3.18.1(云函数侧兜底校验)

另外需要:

第一步:设计数据模型

最少三类集合:

users { _id, _openid, tenantId, role, name }
tenants { _id, name, ownerOpenid, plan }
projects { _id, _openid, tenantId, title, ... } // 业务集合,关键字段是 tenantId

字段约定:

  • users._id 通常等于 _openid(SDK 自动写),后面规则里要用这一点
  • users.tenantId 指向 tenants._id
  • users.roleowner | admin | member,owner 一般是租户创建者
  • 任何业务集合都加 tenantId 字段,SDK 写入时业务代码自己赋值,规则会校验它

这套模型的核心是:前端调用时只传 tenantId 是不够的,规则要去 users 集合验「这个 openid 真的属于这个 tenantId」,不然恶意客户端可以瞎填别家的 tenantId 越权。

第二步:users 集合的规则

控制台 → 数据库 → users → 自定义安全规则:

{
"read": "doc._openid == auth.openid",
"write": "doc._openid == auth.openid && doc.role == 'member'"
}

含义:

  • 只能读自己那条 users 文档(避免被列举出整个租户的成员表)
  • 自己只能写「member」角色的自己,不能把自己的 role 改成 owner / admin。提权动作必须走云函数

如果产品有「邀请加入租户」的流程,创建 users 文档应该走云函数(见第五步),前端不开 create 权限。

第三步:业务集合 projects 的规则

{
"read": "doc.tenantId == get(`database.users.${auth.openid}`).tenantId",
"create": "doc.tenantId == get(`database.users.${auth.openid}`).tenantId && doc._openid == auth.openid",
"update": "doc.tenantId == get(`database.users.${auth.openid}`).tenantId && (doc._openid == auth.openid || get(`database.users.${auth.openid}`).role in ['owner', 'admin'])",
"delete": "doc.tenantId == get(`database.users.${auth.openid}`).tenantId && get(`database.users.${auth.openid}`).role == 'owner'"
}

逐条解读:

  • read:只读同租户的文档
  • create:新建时 tenantId 必须是自己所属租户,_openid 必须是自己(防止冒名)
  • update:同租户前提下,要么是自己创建的、要么是 owner/admin 角色
  • delete:仅 owner 可以删(三级权限里最严格的一档)

几个易疏漏点:

  • get('database.users.${auth.openid}') 每次调用都算一次数据库读,且单条规则里 get() 调用上限是 3 次。上面 update 用了 2 次,已经接近上限,业务复杂时可以把 role / tenantId 直接往业务文档里冗余一份
  • 反引号 ` 里的 ${...} 是 Cloudbase 规则语法的字符串模板,不是 JS,字段名严格按文档写。规则语法以 安全规则文档 当前版本为准。
  • in ['owner', 'admin'] 这种语法在规则里可用,但 in 操作符里数组长度上限是 1,需要展开成两次 ==||。具体限制以官方文档为准

更稳的写法是把 || 拆开:

{
"update": "doc.tenantId == get(`database.users.${auth.openid}`).tenantId && (doc._openid == auth.openid || get(`database.users.${auth.openid}`).role == 'owner' || get(`database.users.${auth.openid}`).role == 'admin')"
}

写规则的过程很容易超长,建议把表达式存到本地,每次复制粘贴到控制台保存。

第四步:tenants 集合的规则

{
"read": "doc._id == get(`database.users.${auth.openid}`).tenantId",
"write": "doc._id == get(`database.users.${auth.openid}`).tenantId && get(`database.users.${auth.openid}`).role == 'owner'"
}

只有「自己所在租户」的那条 tenants 文档可读;只有 owner 可改租户名 / 套餐。

第五步:云函数侧兜底校验

数据库安全规则只对小程序 / Web 端的 SDK 调用生效,云函数走「管理员身份」绕过规则。所以一旦把读写收到云函数里,规则就管不到了,得自己再校一次:

cloudfunctions/createProject/index.js:

const cloudbase = require('@cloudbase/node-sdk');

const app = cloudbase.init({
env: process.env.TCB_ENV || cloudbase.SYMBOL_CURRENT_ENV,
});

const db = app.database();

exports.main = async (event, context) => {
// 关键:用 cloudbase 提供的「调用方身份」拿 openid,不要相信 event.openid
const { OPENID } = cloudbase.getCloudbaseContext(context);
if (!OPENID) {
return { ok: false, error: 'NOT_LOGIN' };
}

// 1. 取当前用户的 tenantId / role
const userRes = await db.collection('users').doc(OPENID).get();
if (!userRes.data) {
return { ok: false, error: 'NOT_IN_ANY_TENANT' };
}
const { tenantId, role } = userRes.data;

// 2. 校验 role
if (role !== 'owner' && role !== 'admin') {
return { ok: false, error: 'NO_PERMISSION' };
}

// 3. 校验前端传来的 tenantId 必须等于服务端拿到的(防止前端伪造)
if (event.tenantId && event.tenantId !== tenantId) {
return { ok: false, error: 'CROSS_TENANT_FORBIDDEN' };
}

// 4. 写入,tenantId 用服务端的版本
const insert = await db.collection('projects').add({
tenantId,
title: event.title,
createdBy: OPENID,
createdAt: db.serverDate(),
});

return { ok: true, id: insert.id };
};

注意:

  • cloudbase.getCloudbaseContext 拿到的 OPENID 是平台从登录态注入的,不要相信 event.openid —— 那个是前端传的字符串,可以伪造
  • 服务端校验通过后,业务字段也用服务端的版本写入,而不是把前端传的 tenantId 直接落库
  • 云函数错误返回结构化对象 { ok, error } 而不是直接 throw,前端容易判断

第六步:验证脚本

准备两个测试账号 userA(tenantA, owner)userB(tenantB, member),验四个场景:

场景期望结果
userA 读自己租户的 project成功
userA 读 tenantB 的 project空数组(规则过滤掉了)
userB 直接 SDK 调用 add({ tenantId: 'tenantA', ... })写入失败,UNAUTHORIZED
userA 直接 SDK 调用 delete()成功(owner 有删除权)
userB 直接 SDK 调用 delete()失败,UNAUTHORIZED(member 没删除权)
云函数 createProject 时 userB 试图传 tenantA返回 CROSS_TENANT_FORBIDDEN

最后两条是云函数兜底校验的核心,如果前两条规则被绕过,云函数能挡住。

常见错误

错误现象原因修复
规则保存按钮变灰JSON 语法错误,常见是反引号和单引号混用JSON 里只能用双引号包字符串,反引号在字符串值内部用 `
get() 报「超过 3 次」规则里 get() 调多了把 role / tenantId 冗余到业务文档,规则里直接读 doc.role / doc.tenantId
跨租户也能读到数据规则里 readtrue 没改,或者 tenantId 字段名拼错控制台 → 数据库 → 集合 → 数据权限,改成「自定义安全规则」并保存
云函数里 OPENID 是 undefined调用方没登录 / 用了非登录态调用前端先 ensureLogin;或者云函数本身限定为「需要登录」才能调
修改 role 之后规则没立即生效users.role 的修改需要走云函数提权和降权都走云函数,前端不开 update 权限

错误码定义参考 error-code

相关文档

下一步