Firestore Data Structure: Example of User Collection and Security Rules

March 6, 2019
Comparing Security/Privacy, Cost, Performance and Convinience

Solution 1: One User Document per User

The most simple solution is to have 1 User Document to store all user data.

name: "Desmond"
email: "desmond.lua@luasoftware.com"
roles: ["user", "admin"]
address: "19, Almond Street, ..."
stat:
  lastLoginDate: "2019-02-28T15:53:49.147"
  sessionCount: 3
active: true

NOTE: We store UserId as Document ID.

There is a few obvious drawbacks with this solution:

  • email is exposed, since Firestore don’t allow partial get/fetch or limit fields.
  • with roles shown to anyone (since everyone should be able to list all users), admin account could be identified and targetted.
  • the document will get bloated (bandwidth concern) and privacy issues, due to private content like stat and address is exposed.

Security Rules for User document.

  • Allow anyone to perform read/get/list. Must signin to perform create/update.
  • Allow sigin user to create User document, where the Document ID ({userId}) must match UserID (request.auth.uid).
  • User can only create/update User with roles = ['user'], while Admin can do anything.
  • Allow owner to create/update with roles data restriction (we don’t want user to assign admin priviledge to themselves). Other users cannot update documents where they are not the owner.
  • Only allow Admin to delete User document.
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }

    match /users/{userId} {
      function isAdmin() {
        return isLogin() && 
          (request.auth.uid == 'QYHqCKCGSbY3qqMSliqNhR3j9QC2' ||
           'admin' in get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles)
      }
      
      function isOwner() {
        return userId == request.auth.uid;
      }
      
      function isLogin() {
        return request.auth != null
      }
      
      function validData() {
        return 'roles' in request.resource.data && request.resource.data.roles == ['user']
      }
      
      // anyone
      allow read: if true;
      // allow get: if true;
      // allow list: if true;
    
      // allow create: if (isLogin() && validUserCreate()) || isAdmin();
      allow create: if (isOwner() && validData()) || isAdmin();
      allow update: if (isOwner() && validData()) || isAdmin();
      allow delete: if isAdmin();
    }   
  }
} 

NOTE: Obserse validData. Even if we perform partial updates without the field roles, request.resource.data.roles will still be populated if existing data contain the field roles.

Solution 2: Public User and Private User Document

Public User: store in users collection.

created: "2019-02-28T15:53:49.147"
name: "Desmond"
bio: "I like ..."
profilePictureUrl: "https://..."
active: true

Private User: store in userPrivates collection.

email: "desmond.lua@luasoftware.com"
roles: ["user", "admin"]
address: "19, Almond Street, ..."
stat:
  lastLoginDate: "2019-02-28T15:53:49.147"
  sessionCount: 3
active: true

NOTE: Both public and private User use UserId as Document ID.

NOTE: Notice active is duplicated on both collections. This depends on your use case, as we might want to duplicate some fields to avoid too much get/fetch (since Firestore pricing depends on number of operations).

You might be thinking of storing userPrivates as subcollection of users (e.g. users/{userId}/privates/{userId}), but doing so would make the following query impossible:

  • Get all users with roles of admin.
  • Get all users with sessionCount more than 100.

The drawbacks with users (Public User) is that it might be slightly bloated.

  • If we query comments with users, we just need name and profilePictureUrl, probably don’t need created or bio.
  • If we view the user’s profile page, then we need the full users data.
This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.