18

I'm working on an iOS app which has (whoah surprise!) chat functionality. The whole app is heavily using the Firebase Tools, for the database I’m using the new Cloud Firestore solution.

Currently I'm in the process of tightening the security using the database rules, but I'm struggling a bit with my own data model :) This could mean that my data model is poorly chosen, but I'm really happy with it, except for implementing the rules part.

The conversation part of the model looks like this. At the root of my database I have a conversations collection:

/conversations/$conversationId
        - owner // id of the user that created the conversation
        - ts // timestamp when the conversation was created
        - members: {
                $user_id_1: true // usually the same as 'owner'
                $user_id_2: true // the other person in this conversation
                ...
          }
        - memberInfo: {
                // some extra info about user typing, names, last message etc.
                ...
          }

And then I have a subcollection on each conversation called messages. A message document is a very simple and just holding information about each sent message.

/conversations/$conversationId/messages/$messageId
        - body
        - sender
        - ts

And a screenshot of the model: the model

The rules on the conversation documents are fairly straightforward and easy to implement:

match /conversations/{conversationId} {
  allow read, write: if resource.data.members[(request.auth.uid)] == true;

  match /messages/{messageId} {
        allow read, write: if get(/databases/$(database)/documents/conversations/$(conversationId)).data.members[(request.auth.uid)] == true;
  }
}

Problem

My problem is with the messages subcollection in that conversation. The above works, but I don’t like using the get() call in there. Each get() call performs a read action, and therefore affects my bill at the end of the month, see documentation.

...

Which might become a problem if the app I’m building will become a succes, the document reads ofcourse are really minimal, but to do it every time a user opens a conversation seems a bit inefficient. I really like the subcollection solution in my model, but not sure how to efficiently implement the rules here.

I'm open for any datamodel change, my goal is to evaluate the rules without these get() calls. Any idea is very welcome.

Gertjan.com
  • 410
  • 1
  • 3
  • 12

2 Answers2

13

Honestly, I think you're okay with your structure and get call as-is. Here's why:

  1. If you're fetching a bunch of documents in a subcollection, Cloud Firestore is usually smart enough to cache values as needed. For example, if you were to ask to fetch all 200 items in "conversions/chat_abc/messages", Cloud Firestore would only perform that get operation once and re-use it for the entire batch operation. So you'll end up with 201 reads, and not 400.

  2. As a general philosophy, I'm not a fan of optimizing for pricing in your security rules. Yes, you can end up with one or two extra reads per operation, but it's probably not going to cause you trouble the same way, say, a poorly written Cloud Function might. Those are the areas where you're better off optimizing.

Todd Kerpelman
  • 16,875
  • 4
  • 42
  • 40
  • 1
    Thanks for your answer! Is there any documentation of the caching part (or talk where this is mentioned)? 2: I agree about the optimizing part, but still, I'm still in the early stage of the development, so I want to set it up the best I can. – Gertjan.com Dec 16 '17 at 10:21
  • 1
    I found what you mentioned [here in the **Cloud Firestore Security Rules** section](https://firebase.google.com/docs/firestore/pricing?authuser=1#operations). I think I'm good. Thanks again :) – Gertjan.com Dec 21 '17 at 09:21
4

If you want to save those extra reads, you can actually implement a "cache" based on custom claims.

You can, for example, save the chats the user has access to in the custom claims under the object "conversations". Keep in mind custom claims has a limit of 1000 bytes as mentioned in their documentation.

One workaround to the limit is to just save the most recent conversations in the custom claims, like the top 50. Then in the security rules you can do this:

allow read, write: if request.auth.token.conversations[conversationId] || get(/databases/$(database)/documents/conversations/$(conversationId)).data.members[(request.auth.uid)] == true;

This is especially great if you're already using cloud functions to moderate messages after they were posted, all you need is to update the custom claims

Helder Esteves
  • 451
  • 4
  • 13
  • Just stumble upon your answer while searching for something else. Want to ask if your approach really save the read operation? Does the "if" evaluation stop as soon as it has one true value or does it evaluate all parameters before coming to conclusion? – Nick Li Jun 21 '20 at 09:04
  • In JavaScript and many other languages, an OR operation stops at the first condition if it's true, while an AND operation stops if the first condition is false. This is known as short-circuiting (https://en.wikipedia.org/wiki/Short-circuit_evaluation). So yes, this "if" will stop once the customClaims condition is true. – Helder Esteves Jun 22 '20 at 12:46
  • 1
    This is clever. I think the get can be implemented initially and if the billing becomes a problem this can be incorporated to reduce it. I would not see this as an initial solution though as it's too hacky imo but a really good solution nonetheless – Ced May 29 '21 at 11:26