1
votes

I have user profile data stored in Firestore. I also have some profile fields in Firestore that dictate user permission levels. I want to deny the user the ability to write or update to their Firestore profile if they include any changes that would impact their permission level.

Example fields in user's firestore doc for their profile

permissionLevel: 1
favoriteColor: red

Document ID is the same as the user's authentication uid (because only the user should be able to read / write / update their profile).

I want to deny updates or writes if the user's firestore update or write includes a permissionLevel field, to prevent the user from changing their own permission level.

Current Firestore Rules

This is working fine when I build an object in the simulator to test including or not including a field called "permissionLevel". But this is denying all update / write requests from my client-side web SDK.

service cloud.firestore {
  match /databases/{database}/documents {

    // Deny all access by default
    match /{document=**} {
      allow read, write: if false;
    }

    // Allow users to read, write, and update their own profiles only
    match /users/{userId} {
        // Allow users to read their own profile
      allow read: if request.auth.uid == userId;

      // Allow users to write / update their own profile as long as no "permissionLevel" field is trying to be added or updated
      allow write, update: if request.auth.uid == userId &&
            request.resource.data.keys().hasAny(["permissionLevel"]) == false;
    }
  }
}

Client-Side Function

For example, this function attempts to just update when the user was last active by updating a firestore field. This returns the error Error updating user refresh time: Error: Missing or insufficient permissions.

/**
 * Update User Last Active
 * Updates the user's firestore profile with their "last active" time
 * @param {object} user is the user object from the login auth state
 * @returns {*} dispatched action
 */
export const updateLastActive = (user) => {
    return (dispatch) => {
        firestore().settings({/* your settings... */ timestampsInSnapshots: true});

        // Initialize Cloud Firestore through Firebase
        var db = firestore();

        // Get the user's ID from the user retrieved user object
        var uid = firebaseAuth().currentUser["uid"];

        // Get last activity time (last time token issued to user)
        user.getIdTokenResult()
        .then(res => {
            let lastActive = res["issuedAtTime"];

            // Get reference to this user's profile in firestore
            var userRef = db.collection("users").doc(uid);

            // Make the firestore update of the user's profile
            console.log("Firestore write (updating last active)");
            return userRef.update({
                "lastActive": lastActive
            })
        })
        .then(() => {
            // console.log("User lastActive time successfully updated.");
        })
        .catch(err => {
            console.log("Error updating user refresh time: ", err);
        })
    }
}

This same function works fine if I remove this line from the firestore rules. I don't see how they have anything to do with each other, and why it would work fine in the simulator. request.resource.data.keys().hasAny(["permissionLevel"]) == false;

2

2 Answers

4
votes

Update

I got a notice that writeFields is deprecated. I have another another answer to a similar question here which uses request.resource.data which may be an alternative that is useful to people who arrive here.


Original Answer

OK, I found a solution, but I can't find any official documentation in the firebase docs to support this. It doesn't work in the simulation, but it works IRL.

Replace (from my example above)

request.resource.data.keys().hasAny(["permissionLevel"]) == false

With This

!("permissionLevel" in request.writeFields);

Full Working Permissions Example

service cloud.firestore {
  match /databases/{database}/documents {
  
    // Deny all access by default
    match /{document=**} {
      allow read, write: if false;
    }
    
    // Allow users to read, write, and update their own profiles only
    match /users/{userId} {
        // Allow users to read their own profile
      allow read: if request.auth.uid == userId;
      
      // Allow users to write / update their own profile as long as no "admin" field is trying to be added or created
      allow write, update: if request.auth.uid == userId &&
            !("permissionLevel" in request.writeFields);
    }
  }
}

This successfully prevents an update or write whenever the key permissionLevel exists in the firestore request map object, and allows other updates as intended.

Documentation Help

Firestore Security Docs Index lists "rules.firestore.Request#writeFields" - but when you click it, the resulting page doesn't even mention "writeFields" at all.

I used the principles based on rules.Map for

k in x Check if key k exists in map x

1
votes

Two other things you could consider doing for adding permission levels:

  1. Create a separate subcollection for the user that will then contain a document with information you do not want the user to be able to change. That document can be given different permission controls.
  2. Use Firebase Auth Tokens with Custom Claims. Bonus: this method will not trigger reads on the database. I recommend checking out these Firecasts:

Add the Firebase Admin SDK to Your Server guide is also very helpful.

I am new to the development game, but this what I use to manually create custom claims using ItelliJ IDEA:

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseAuthException;
import com.google.firebase.auth.UserRecord;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

public class App {

    //This will be the UID of the user we modify
    private static final String UID = "[uid of the user you want to modify]"; 

    //Different Roles
    private static final int USER_ROLE_VALUE_BASIC = 0; //Lowest Level - new user
    private static final int USER_ROLE_VALUE_COMMENTER = 1; 
    private static final int USER_ROLE_VALUE_EDITOR = 2; 
    private static final int USER_ROLE_VALUE_MODERATOR = 3; 
    private static final int USER_ROLE_VALUE_SUPERIOR = 4; 
    private static final int USER_ROLE_VALUE_ADMIN = 9;

    private static final String FIELD_ROLE = "role";

    //Used to Toggle Different Tasks - Currently only one task
    private static final boolean SET_PRIVILEGES = true; //true to set User Role

    //The privilege level being assigned to the uid.
    private static final int SET_PRIVILEGES_USER_ROLE = USER_ROLE_VALUE_BASIC;

    public static void main(String[] args) throws IOException {

        // See https://firebase.google.com/docs/admin/setup for setting this up
        FileInputStream serviceAccount = new FileInputStream("./ServiceAccountKey.json");

        FirebaseOptions options = new FirebaseOptions.Builder()
                .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                .setDatabaseUrl("https://[YOUR_DATABASE_NAME].firebaseio.com")
                .build();

        FirebaseApp.initializeApp(options);

        // Set privilege on the user corresponding to uid.
        if (SET_PRIVILEGES){
            Map<String, Object> claims = new HashMap<>();
            claims.put(FIELD_ROLE, SET_PRIVILEGES_USER_ROLE);
            try{
                // The new custom claims will propagate to the user's ID token the
                // next time a new one is issued.
                FirebaseAuth.getInstance().setCustomUserClaims(UID, claims);

                // Lookup the user associated with the specified uid.
                UserRecord user = FirebaseAuth.getInstance().getUser(
                System.out.println(user.getCustomClaims().get(FIELD_ROLE));

            }catch (FirebaseAuthException e){
                System.out.println("FirebaseAuthException caught: " + e);
            }
        }

    }
}

The build.gradle dependency is currently:

implementation 'com.google.firebase:firebase-admin:6.7.0'