Skip to main content

Multi-tenant Isolation with CloudBase Database Security Rules

In one sentence: Record tenantId and role for each user in the users collection; use get('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

DependencyVersion
@cloudbase/js-sdk2.27.3
@cloudbase/node-sdk3.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._id is typically equal to _openid (auto-written by the SDK); the rules below rely on this
  • users.tenantId points to tenants._id
  • users.role is one of owner | admin | member; owner is typically the tenant creator
  • Every business collection includes a tenantId field assigned by business code on write; the rules verify it

The core of this model: passing tenantId from the frontend alone is not sufficient. The rule must verify via the users collection that "this openid truly belongs to this tenantId" — otherwise a malicious client can forge another company's tenantId to 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 users document 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 tenant
  • create: on creation, tenantId must match the user's own tenant, and _openid must be the current user (prevents impersonation)
  • update: within the same tenant, either the creator or a user with owner/admin role
  • delete: 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 3 get() calls. The update rule above uses 2, which is close to the limit. For complex business logic, consider denormalizing role / tenantId directly into business documents and reading doc.role / doc.tenantId in 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 the in operator 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.getCloudbaseContext is injected by the platform from the login state. Do not trust event.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 tenantId sent 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:

ScenarioExpected result
userA reads their own tenant's projectSuccess
userA reads tenantB's projectEmpty 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 tenantAReturns 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 symptomCauseFix
Save button for rules is greyed outJSON syntax error; common cause is mixing backticks and single quotesIn 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 rulesDenormalize role / tenantId into business documents and read doc.role / doc.tenantId directly in the rules
Cross-tenant data is readableread rule is still true and was not changed, or the tenantId field name is misspelledConsole → Database → Collection → Data Permission → change to "Custom Security Rules" and save
OPENID is undefined in Cloud FunctionCaller is not logged in, or the call was made without a login stateFrontend calls ensureLogin first; or restrict the Cloud Function to require login
Rule does not take effect immediately after role changeChanges to users.role require going through a Cloud FunctionRoute role elevation and demotion through Cloud Functions; do not expose update permission to the frontend

For error code definitions, see error-code.

Next Steps