Search code examples
firebasegoogle-cloud-firestorefirebase-security

What is the most read efficient way to allow "approved" users to read data in Firestore?


I have two classes of users - approved and unapproved. Both have authenticated with Firebase, but approved is allowed to read a broader class of documents than unapproved is. One way I could implement this is by storing a document for each user with their approved status, and then checking /users/{uid} for status=="Approved" in the Firestore rules as the read condition. However, this would double the amount of reads in my project and thus my costs with respect to reads - is there a more efficient way to do this? It isn't scalable to hardcode the UIDs into the security rules.


Solution

  • Firebase allows setting of custom claims within user authentication tokens, which is available to Firestore rules via the request.auth.token map. Although it is not typical to use authentication tokens in an authorization context, because previously-issued authentication tokens will remain valid until their expiration in most systems (after which a refresh token can be used to obtain a new authentication token), you could potentially use this method along with a document listener to listen for updates to the user status in order to force a token refresh.

    Example

    Cloud Functions document listener

    This listens to the user's document to set a claim via the admin API when the status is changed:

    import functions from 'firebase-functions';
    import admin from 'firebase-admin';
    
    export const updateUserStatusClaim = functions.firestore
      .document('users/{userId}')
      .onWrite((change, context) => {
        const statusAfter = change.after.data().status;
        if (change.before.data().status !== statusAfter) {
          const userId = context.params.userId;
          const auth = admin.auth();
    
          auth.getUser(userId)
            .then((user) => {
              const updatedCustomClaims = Object.assign(
                {},
                user.customClaims,
                { status: statusAfter }
              );
              auth.setCustomUserClaims(userId, updatedCustomClaims)
                .then(() => {
                  // Update another document for the client listener.
                  // This makes sure that the client does not refresh
                  // before the custom claims are set.
                  admin.firestore()
                    .doc(`userClaims/${userId}`)
                    .set(
                      { status: statusAfter },
                      { merge: true }
                    );
                });
            });
        }
      });
    
    Note

    There is an asynchronous issue using this method for updating the claims:

    • (A.1) creates an updatedCustomClaims object with updated status.
    • (B.1) creates an updatedCustomClaims object with updated otherClaim (does not contain the updated status).
    • (A.2) sets the user's custom claims.
    • (B.2) sets the user's custom claims.

    The user's custom claims would not contain the updated status, but would contain the updated otherClaim.

    A better method would be to use a separate document for storage of the user's custom claims, along with another document for clients to listen to. Even this method may have asynchronous issues, though:

    • (A.3) handles the write event for the claims document, updated with status.
    • (B.3) handles the write event for the claims document, updated with otherClaim (and status).
    • (B.4) sets the user's custom claims.
    • (A.4) sets the user's custom claims.

    The user's custom claims would not contain the updated otherClaim, but would contain the updated status.

    Client document listener

    This listens to the userClaims document for changes. This document is updated only after the custom user claims have been set by the Cloud Functions document listener:

    import firebase from 'firebase';
    
    // assuming you have already called firebase.initializeApp()
    const auth = firebase.auth();
    const firestore = firebase.firestore();
    
    let uid = undefined;
    let userClaimsUnsubscriber = undefined;
    auth.onAuthStateChanged((user) => {
      if (
        userClaimsUnsubscriber !== undefined &&
        (user === null || user.uid !== uid)
      ) {
        if (user === null) {
          uid = undefined;
        }
        userClaimsUnsubscriber();
        userClaimsUnsubscriber = undefined;
      }
      if (user !== null && userClaimsUnsubscriber === undefined) {
        uid = user.uid;
        userClaimsUnsubscriber = firestore
          .doc(`userClaims/${uid}`)
          .onSnapshot({
            next: (snapshot) => {
              // Force the client to refresh the token.
              // In this example, we don't really care about
              // the returned Promise.
              user.getIdToken(true);
            }
          });
      }
    });
    

    As mentioned in a comment to the original post, there is no guarantee that a client will adhere to refreshing their token when the userClaims document is updated. So, if a user is moved from approved to unapproved status, they may still have access to approved user status functionality until their token naturally expires.