Multi-tenant Isolation with CloudBase Database Security Rules
In one sentence: Record
tenantIdandrolefor each user in theuserscollection; useget('database.users.${auth.openid}')in business collection Security Rules to look up the current user's tenant identity cross-collection, enforcing "visible within the same tenant + different roles have different write permissions"; a Cloud Function layer adds a backstop to guard against rule omissions.Estimated time: 45 minutes | Difficulty: Advanced
Applicable Scenarios
- Applicable: SaaS Mini Program / Web apps where a single CloudBase environment serves multiple client companies
- Applicable: The same user has different roles in different tenants (owner / admin / member)
- Not applicable: Each customer has a dedicated CloudBase environment (that is physical isolation; this article covers logical isolation)
- Not applicable: Financial scenarios with strict compliance audit requirements. Security Rules only block frontend SDK calls; they cannot replace auditing / encryption
Prerequisites
| Dependency | Version |
|---|---|
@cloudbase/js-sdk | 2.27.3 |
@cloudbase/node-sdk | 3.18.1 (Cloud Function fallback verification) |
Also required:
- Already completed add-auth-wechat-miniprogram (similar for other platforms: Web / Taro / UniApp)
- Already completed add-database-wechat-miniprogram, familiar with Permission Mode selection
- Ability to set "Custom Security Rules" on collections in Console "Database → Collection Management"
Step 1: Design the Data Model
At minimum three types of collections:
users { _id, _openid, tenantId, role, name }
tenants { _id, name, ownerOpenid, plan }
projects { _id, _openid, tenantId, title, ... } // business collection; key field is tenantId
Field conventions:
users._idis typically equal to_openid(auto-written by the SDK); the rules below rely on thisusers.tenantIdpoints totenants._idusers.roleis one ofowner | admin | member; owner is typically the tenant creator- Every business collection includes a
tenantIdfield assigned by business code on write; the rules verify it
The core of this model: passing
tenantIdfrom the frontend alone is not sufficient. The rule must verify via theuserscollection that "this openid truly belongs to this tenantId" — otherwise a malicious client can forge another company'stenantIdto escalate privileges.
Step 2: Security Rules for the users Collection
Console → Database → users → Custom Security Rules:
{
"read": "doc._openid == auth.openid",
"write": "doc._openid == auth.openid && doc.role == 'member'"
}
Meaning:
- Only the user's own
usersdocument can be read (prevents enumerating the full member list of a tenant) - A user can only write to their own document at the "member" role level; they cannot change their own role to owner / admin. Role elevation must go through a Cloud Function
If the product has a "join tenant via invitation" flow, creating users documents should go through a Cloud Function (see Step 5); the frontend does not have create permission.
Step 3: Security Rules for the projects Business Collection
{
"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'"
}
Line by line:
read: only read Documents from the same tenantcreate: on creation,tenantIdmust match the user's own tenant, and_openidmust be the current user (prevents impersonation)update: within the same tenant, either the creator or a user with owner/admin roledelete: owner only (the strictest level among the three tiers)
Easy-to-miss points:
- Each call to
get('database.users.${auth.openid}')counts as one database read; the limit per rule is 3get()calls. Theupdaterule above uses 2, which is close to the limit. For complex business logic, consider denormalizingrole/tenantIddirectly into business documents and readingdoc.role/doc.tenantIdin the rule instead - The backtick
`and${...}in the expression are CloudBase rule syntax string templates, not JavaScript; field names must strictly follow the documentation. Rule syntax is subject to the current version of the Security Rules documentation. - The
in ['owner', 'admin']syntax is available in rules, but the array length limit for theinoperator is 1; expand to two==conditions joined with||. Verify the specific limit in the official documentation
A more robust alternative that avoids the in limit:
{
"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')"
}
Rule expressions can get long quickly; it is recommended to store them locally and copy-paste into the Console to save.
Step 4: Security Rules for the tenants Collection
{
"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'"
}
Only the tenants Document for the user's own tenant is readable; only the owner can modify the tenant name / plan.
Step 5: Cloud Function Fallback Verification
Database Security Rules apply only to SDK calls from Mini Programs / Web frontends; Cloud Functions run with admin identity and bypass the rules. Once reads and writes are routed through a Cloud Function, the rules no longer apply and must be re-verified in code:
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) => {
// Key: get openid from the platform-injected caller identity; do not trust event.openid
const { OPENID } = cloudbase.getCloudbaseContext(context);
if (!OPENID) {
return { ok: false, error: 'NOT_LOGIN' };
}
// 1. Get the current user's 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. Verify role
if (role !== 'owner' && role !== 'admin') {
return { ok: false, error: 'NO_PERMISSION' };
}
// 3. Verify that the tenantId passed from the frontend matches the server-side value (prevents forgery)
if (event.tenantId && event.tenantId !== tenantId) {
return { ok: false, error: 'CROSS_TENANT_FORBIDDEN' };
}
// 4. Write using the server-side tenantId
const insert = await db.collection('projects').add({
tenantId,
title: event.title,
createdBy: OPENID,
createdAt: db.serverDate(),
});
return { ok: true, id: insert.id };
};
Notes:
- The OPENID obtained from
cloudbase.getCloudbaseContextis injected by the platform from the login state. Do not trustevent.openid— that is a string passed by the frontend and can be forged - After server-side verification, use the server-side version of the business field for the write, rather than persisting the
tenantIdsent by the frontend - Cloud Function error responses return a structured object
{ ok, error }rather than throwing; easier for the frontend to handle
Step 6: Verification Script
Prepare two test accounts: userA (tenantA, owner) and userB (tenantB, member). Verify four scenarios:
| Scenario | Expected result |
|---|---|
| userA reads their own tenant's project | Success |
| userA reads tenantB's project | Empty array (filtered by rules) |
userB directly calls SDK add({ tenantId: 'tenantA', ... }) | Write fails, UNAUTHORIZED |
userA directly calls SDK delete() | Success (owner has delete permission) |
userB directly calls SDK delete() | Fails, UNAUTHORIZED (member has no delete permission) |
| Cloud Function createProject: userB tries to pass tenantA | Returns CROSS_TENANT_FORBIDDEN |
The last two rows are the core of the Cloud Function fallback. If the first two rules are bypassed, the Cloud Function blocks it.
Common Errors
| Error symptom | Cause | Fix |
|---|---|---|
| Save button for rules is greyed out | JSON syntax error; common cause is mixing backticks and single quotes | In JSON, strings can only be wrapped in double quotes; backticks are used inside the string value itself |
get() reports "exceeded 3 calls" | Too many get() calls in the rules | Denormalize role / tenantId into business documents and read doc.role / doc.tenantId directly in the rules |
| Cross-tenant data is readable | read rule is still true and was not changed, or the tenantId field name is misspelled | Console → Database → Collection → Data Permission → change to "Custom Security Rules" and save |
| OPENID is undefined in Cloud Function | Caller is not logged in, or the call was made without a login state | Frontend calls ensureLogin first; or restrict the Cloud Function to require login |
| Rule does not take effect immediately after role change | Changes to users.role require going through a Cloud Function | Route role elevation and demotion through Cloud Functions; do not expose update permission to the frontend |
For error code definitions, see error-code.
Related Documentation
- Database Security Rules — rule syntax and built-in variables
- Cloud Function Context and Login State —
getCloudbaseContextto get OPENID - add-auth-wechat-miniprogram — prerequisite: login integration
- add-database-wechat-miniprogram — prerequisite: database read/write
Next Steps
- Sharing in multi-tenant scenarios: add-share-with-params-miniprogram
- Send Subscribe Messages to tenant admins: add-subscribe-message-cloud-function
- Cross-tenant batch processing scripts: schedule-cloud-function-cron-job