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​