Cloud Firestore多租户数据模型设计可行性及方案问询
Absolutely—this multi-tenant model is totally feasible with Cloud Firestore, and it’s actually a common pattern that plays to Firestore’s strengths in flexible document modeling and granular security rules. Let’s walk through the design step by step, covering data structures, access controls, and core workflows.
Short answer: Yes, absolutely. Firestore’s document-based structure and customizable security rules make it ideal for building isolated multi-tenant systems with role-based access and cross-user visibility controls.
We’ll structure the database into 4 core collections to handle organizations, users, invitations, and your business-specific data.
1. organizations Collection
Each document represents a single tenant organization. Use a unique document ID (either auto-generated or tied to the founder’s UID for uninvited users) and include these fields:
name: Human-readable name of the organization (e.g., "Jane’s Project Hub")founderUid: Firebase Auth UID of the organization’s creatorcreatedAt: Server timestamp for when the org was createdallowCrossUserView: Boolean flag to control if members can view each other’s data (defaults totruefor uninvited founders)
Example document:
{ "name": "John’s Personal Workspace", "founderUid": "abc123XYZ", "createdAt": "2024-05-20T14:30:00Z", "allowCrossUserView": true }
2. users Collection
Each document uses the user’s Firebase Auth UID as its ID, storing their profile and organization memberships. Memberships are stored as an object where keys are organization IDs and values are roles (e.g., admin, member, viewer):
displayName: User’s full nameemail: User’s registered emailorganizations: Object mapping org IDs to roles (ensures users can belong to multiple orgs if needed)
Example document:
{ "displayName": "John Doe", "email": "john@example.com", "organizations": { "org_abc123XYZ": "admin", "org_789QRS": "member" } }
3. invitations Collection
Stores pending invitations from founders to new users. Each document includes:
orgId: ID of the organization the user is being invited toinvitedEmail: Email of the recipientrole: Role the user will get (e.g.,member)invitedByUid: UID of the founder/admin sending the inviteexpiresAt: Timestamp for when the invite expires (to clean up old invites)
4. Business Data Collections (e.g., projects, tasks)
All your application’s core data (like projects, tasks, or client records) must include an orgId field to tie it to an organization. Add a createdByUid field to track who created the document for granular access:
orgId: ID of the owning organizationcreatedByUid: UID of the user who created the document- ... (your custom business fields)
Example projects document:
{ "orgId": "org_abc123XYZ", "createdByUid": "abc123XYZ", "title": "Q2 Marketing Campaign", "description": "Launch social media ads for new product", "dueDate": "2024-06-30T00:00:00Z" }
Security rules are critical to enforce isolation and access controls. Here’s a simplified set of rules that align with your requirements:
service cloud.firestore { match /databases/{database}/documents { // Helper function: Check if the user belongs to a specific organization function userBelongsToOrg(orgId) { let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid)).data; return orgId in userDoc.organizations; } // Helper function: Check if the user is an admin of a specific organization function userIsOrgAdmin(orgId) { let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid)).data; return userDoc.organizations[orgId] == "admin"; } // Helper function: Get organization settings for cross-user visibility function orgAllowsCrossUserView(orgId) { return get(/databases/$(database)/documents/organizations/$(orgId)).data.allowCrossUserView; } // Organizations collection rules match /organizations/{orgId} { allow read: if userBelongsToOrg(orgId); allow write: if userIsOrgAdmin(orgId); } // Users collection rules match /users/{userId} { allow read, write: if userId == request.auth.uid; // Allow org admins to view members of their organization (optional) allow read: if userIsOrgAdmin(resource.data.organizations.keys().hasAny([orgId])) } // Invitations collection rules match /invitations/{invitationId} { allow read: if request.auth.uid == resource.data.invitedByUid || request.auth.token.email == resource.data.invitedEmail; allow write: if userIsOrgAdmin(resource.data.orgId); } // Business data example: Projects collection match /projects/{projectId} { // Allow read if user is in the org AND (org allows cross-user view OR user is the creator) allow read: if userBelongsToOrg(resource.data.orgId) && (orgAllowsCrossUserView(resource.data.orgId) || resource.data.createdByUid == request.auth.uid); // Allow write if user is in the org AND (user is admin OR user is the creator) allow write: if userBelongsToOrg(resource.data.orgId) && (userIsOrgAdmin(resource.data.orgId) || resource.data.createdByUid == request.auth.uid); } } }
1. Uninvited User Registration
When a user signs up without an invitation, automatically create their personal organization and assign them as admin using a Firebase Cloud Function:
const functions = require("firebase-functions"); const admin = require("firebase-admin"); admin.initializeApp(); exports.createPersonalOrgOnSignup = functions.auth.user().onCreate(async (user) => { // Create a unique org ID tied to the user's UID const orgId = `org_${user.uid}`; // Create the organization document await admin.firestore().collection("organizations").doc(orgId).set({ name: `${user.displayName || "My"} Workspace`, founderUid: user.uid, createdAt: admin.firestore.FieldValue.serverTimestamp(), allowCrossUserView: true }); // Create/update the user document with org membership await admin.firestore().collection("users").doc(user.uid).set({ displayName: user.displayName, email: user.email, organizations: { [orgId]: "admin" } }); });
2. Invitation Flow
- Founder creates an invite: Write an
invitationsdocument with the recipient’s email, org ID, and role. - Recipient signs up: After signing up, they can accept the invite via a frontend action that triggers a Cloud Function:
exports.acceptInvitation = functions.https.onCall(async (data, context) => { if (!context.auth) { throw new functions.https.HttpsError("unauthenticated", "You must be logged in to accept an invite."); } const { invitationId } = data; const invitationDoc = await admin.firestore().collection("invitations").doc(invitationId).get(); if (!invitationDoc.exists) { throw new functions.https.HttpsError("not-found", "Invitation does not exist."); } const inviteData = invitationDoc.data(); if (inviteData.invitedEmail !== context.auth.token.email) { throw new functions.https.HttpsError("permission-denied", "This invitation is not for you."); } // Add the user to the organization await admin.firestore().collection("users").doc(context.auth.uid).update({ [`organizations.${inviteData.orgId}`]: inviteData.role }); // Delete the used invitation await invitationDoc.ref.delete(); return { success: true, message: "Successfully joined the organization." }; });
- Always filter queries by
orgId: When fetching business data, always includewhere("orgId", "==", currentUserOrgId)to avoid accidental cross-tenant access and optimize performance. - Clean up old invitations: Use a scheduled Cloud Function to delete expired invites from the
invitationscollection. - Role granularity: Expand roles beyond
adminandmember(e.g.,editor,viewer) if you need more fine-grained access controls. - Monitor access logs: Use Firebase’s Audit Logs to track access to sensitive data and debug permission issues.
内容的提问来源于stack exchange,提问作者pulkitsinghal

