Firestore rules and query for document map with email keys to share data with users

Use Case Summary

  1. User A creates a story
  2. User A shares story with unknown (to the app) User B via email (sent via cloud function)
  3. User B receives an email about the story
  4. User B visits app and creates a new account
  5. User B sees/reads story create by User A

Note: stories can only be seen by whom they been shared with or created by

I'm building a role based access system. I've been looking at the role based access firestore documentation and I'm missing one piece.

Consider a story that can only be read by a user for which that story has been shared. Most examples including the firestore example use the UID has the key to identify shared users. However, that user may not currently be a user of the firebase app additionally how does a user assign that UID.

Story Data

{
  title: "A Great Story",
  roles: {
    aliceUID: {
      hasRole: true,
      type: "owner",
    },
    bobUID: {
      hasRole: true,
      type: "reader",
    }
  }
}

Story Query

firebase.firestore().collection('stories').where(`roles.${user.uid}.hasRole`, '==', true)

The second part could potentially be solved by maintaining a separate user collection then you could find the user from their email address, but this doesn't address users that have never logged in.

The user who intends to share a story could add the user with an email address. Then using firebase functions we could send an email to notify the user of the shared story and the user could login to the app and read that story.

If we proceed with this method then you would not have a UID but only an email address as the key.

Story Data

{
  title: "A Great Story",
  roles: {
    alice@yahoo.com: {
      hasRole: true,
      type: "owner",
    },
    bob@gmail.com: {
      hasRole: true,
      type: "reader",
    }
  }
}

Story Query

firebase.firestore().collection('stories').where(`roles.${user.email}.hasRole`, '==', true)

Updated Firestore Rule - from documentation

function getRole(rsc) {
  // Read from the "roles" map in the story document.
  return rsc.data.roles[request.auth.uid] || rsc.data.roles[request.auth.token.email];
}

I can not get the email query to work. This SO issue mentions that

Unfortunately dots are not allowed as a map key. So email addresses won't work.

I don't see why this would be a conflict on the rules side. It does make for a likely invalid where clause

e.g.

.where(`roles.${user.email}.hasRole`, '==', true) -> .where(`roles.bob@gmail.com.hasRole`, '==', true)

That looks like invalid JS and unfortunately [ and ] are invalid characters so we can't do

.where(`roles[${user.email}]hasRole`, '==', true)

The final thing I've seen is using for this Firebase talk is to escape the email address using something like

function encodeAsFirebaseKey(string) {
  return string.replace(/\%/g, '%25')
    .replace(/\./g, '%2E')
    .replace(/\#/g, '%23')
    .replace(/\$/g, '%24')
    .replace(/\//g, '%2F')
    .replace(/\[/g, '%5B')
    .replace(/\]/g, '%5D');
};

This appears to fix the query where clause and it's a valid data structure, but it's not a valid Firestore rule meaning it has no true security enforcement.

Any ideas on how to implement this? Please include valid data structure, firestore rules, and query. I've shown and seen many examples that get two out of the three which are all non-working solutions.

Thanks!

2 answers

  • answered 2018-03-13 23:23 Drei

    From what I have gathered, you want to make a story private but shareable with anyone. Your biggest concern is for users who do not have the app but have the share link.

    And therefore your biggest problem is that the way firebase works means that you cant limit access to your data without using some sort of login.

    If you are ok with requiring new users to login, then your answer should just be Dynamic Links. These links are persistent all the way though installation and login which means that anyone can be given a dynamic link that has story access data attached. You would merely need to add a listener to your app's mainActivity or AppDelegate equivalent to record the dynamic link data and run a specif task after login.

    If you wish to stay away from the login completely, then you set up the dynamic link to bypass the login process and direct the new-install-user directly to the story. This second option however, requires a bit more work and is less secure because you will probably be forced to duplicate the story data for open access to anyone with the proper link to the story.

  • answered 2018-03-14 03:07 Ron Royston

    This is a use case for Control Access with Custom Claims and Security Rules.

    The Firebase Admin SDK supports defining custom attributes on user accounts. This provides the ability to implement various access control strategies, including role-based access control, in Firebase apps. These custom attributes can give users different levels of access (roles), which are enforced in an application's security rules.

    User roles can be defined for the following common cases:

    • Giving a user administrative privileges to access data and resources.
    • Defining different groups that a user belongs to.
    • Providing multi-level access:
    • Differentiating paid/unpaid subscribers.
    • Differentiating moderators from regular users.
    • Teacher/student application, etc.

    You'll need to stand up a node server (skill level low). A script like below works to generate the claims.

    var admin = require('firebase-admin');
    
    var serviceAccount = require("./blah-blah-blah.json");
    
    admin.initializeApp({
        credential: admin.credential.cert(serviceAccount),
        databaseURL: "https://my-app.firebaseio.com"
    });
    
    admin.auth().setCustomUserClaims("9mB3asdfrw34ersdgtCk1", {admin: true}).then(() => {
        console.log("Custom Claim Added to UID. You can stop this app now.");
    });
    

    Then on your client side, do something like:

    firebase.auth().onAuthStateChanged(function(user) { if (user) {

        //is email address up to date? //do we really want to modify it or mess w it?
        switch (user.providerData[0].providerId) {
            case 'facebook':
            case 'github':
            case 'google':
            case 'twitter':
                break;
            case 'password':
                // if (!verifiedUser) {
                // }
                break;
        }
    
        //if admin
        firebase.auth().currentUser.getIdToken().then((idToken) => {
            // Parse the ID token.
            const payload = JSON.parse(window.atob(idToken.split('.')[1]));
            // Confirm the user is an Admin or whatever
            if (!!payload['admin']) {
                switch (thisPage) {
                    case "/admin":
                        showAdminStuff();
                        break;
                }
            }
            else {
                if(isAdminPage()){
                    document.location.href="/";
                }
            }
        })
        .catch((error) => {
            console.log(error);
        });
    }
    else {
        //USER IS NOT SIGNED IN
    
    }
    

    });