用 Cloudbase 数据库安全规则做多租户隔离
一句话定义:在
users集合里给每个用户记tenantId和role,业务集合的安全规则用get('database.users.${auth.openid}')跨集合拿当前用户的租户身份,实现「同一租户内可见 + 不同 role 可写程度不同」,云函数侧再做一道兜底,防止规则写漏。预计耗时:45 分钟 | 难度:进阶
适用场景
- 适用:SaaS 类小程序 / Web 应用,一个 Cloudbase 环境承载多个客户公司
- 适用:同一个用户在不同租户里有不同角色(owner / admin / member)
- 不适用:每个客户独占一个 Cloudbase 环境(那是物理隔离,本篇是逻辑隔离)
- 不适用:对租户数据有强合规审计要求的金融场景。规则只能拦住前端 SDK 调用,不能替代审计 / 加密
环境要求
| 依赖 | 版本 |
|---|---|
@cloudbase/js-sdk | 2.27.3 |
@cloudbase/node-sdk | 3.18.1(云函数侧兜底校验) |
另外需要:
- 已完成 add-auth-wechat-miniprogram(其他端类似,Web / Taro / UniApp 都可)
- 已完成 add-database-wechat-miniprogram,知道集合权限模式怎么选
- 在控制台「数据库 → 集合管理」能给集合设「自定义安全规则」
第一步:设计数据模型
最少三类集合:
users { _id, _openid, tenantId, role, name }
tenants { _id, name, ownerOpenid, plan }
projects { _id, _openid, tenantId, title, ... } // 业务集合,关键字段是 tenantId
字段约定:
users._id通常等于_openid(SDK 自动写),后面规则里要用这一点users.tenantId指向tenants._idusers.role是owner | 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 |
| 跨租户也能读到数据 | 规则里 read 是 true 没改,或者 tenantId 字段名拼错 | 控制台 → 数据库 → 集合 → 数据权限,改成「自定义安全规则」并保存 |
| 云函数里 OPENID 是 undefined | 调用方没登录 / 用了非登录态调用 | 前端先 ensureLogin;或者云函数本身限定为「需要登录」才能调 |
| 修改 role 之后规则没立即生效 | users.role 的修改需要走云函数 | 提权和降权都走云函数,前端不开 update 权限 |
错误码定义参考 error-code。
相关文档
- 数据库安全规则 — 规则语法和内置变量
- 云函数 context 与登录态 —
getCloudbaseContext取 OPENID - add-auth-wechat-miniprogram — 前置:登录接入
- add-database-wechat-miniprogram — 前置:数据库读写
下一步
- 多租户场景的分享:add-share-with-params-miniprogram
- 给租户管理员发订阅消息:add-subscribe-message-cloud-function
- 跨租户的批处理脚本:schedule-cloud-function-cron-job