Let’s say you've got a chat app where you want to enable a private conversation among a select group of people. Or you have a photo sharing app where you want a group of friends to contribute to an album together. How could you limit this kind of data sharing to a small group of users without exposing it to the world?
This is where security rules in Firebase can help out. Firebase's security rules can be quite powerful, but they do sometimes require a little guidance. This isn't really because they're complicated, but mostly because most people tend to not use them frequently enough to build up a lot of expertise.
Luckily for all of you, I sit next to people who have built up some expertise in Firebase security, and I've been able to pester them over the last several weeks to get this blog post written. More importantly, I found this One Weird Trick that makes it easy to figure out security rules that I'll share with you… at the end of this article.
But for the moment, let's go back to our hypothetical example of a chat app that wants to have private group conversations. Anybody who's part of the chat group can read and write chat messages, but we don't want other people to be able to listen in.
Imagine that we've structured our database like so. There are lots of ways to do this, of course, but this is probably the easiest for demonstration purposes.
Within every semi-private chat, we have a list of people who are allowed to participate in the chat, along with the list of chat messages. (And yes, in real life, these userIDs are going to look a whole lot messier than user_abc.)
So the first security rule we want to set up is that only people in the members list are allowed to see chat messages. That's something we could create using a set of security rules like this:
{ "rules": { "chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()" } } } } }
What we're saying here is that you're allowed to read the chats in the chats//messages, as long as your userID exists in that same chat's members section.
chats//messages
userID
members
Curious about that $chatID line? That's kind of the equivalent of a wildcard that matches anything, but sticks the match into a $chatID variable that you could reference later if you want.
$chatID
So user_abc? Totally able to read chat messages. But user_xyz isn't allowed because there's no members/user_xyz entry within that chat group.
user_abc
user_xyz
members/user_xyz
Once we've done that, it's trivial to add a similar rule that says only members can write chat messages, too.
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).exists()" } } }
And we could get more fine-grained if we wanted. What if our chat app had a "lurker" user type, who was allowed to view, but not write messages?
"lurker"
That could be addressed as well. We want to change our rules to say, "You can write messages, but only if you're listed as an owner or a chatter." So we'd end up with something like this:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" } } }
(Note that for brevity, I've dropped the "rules" line from the rest of these samples.)
Incidentally, you might thinking to yourself, "Gosh wouldn't it be easier to just say 'Allow people to write chat messages only if they're not listed as a lurker'"? And sure, that would require one less line of code…
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() != 'lurker'" } }
...but quite often, when it comes to security, you're better off basing your security off of whitelists rather than blacklists. Consider this: What would happen if your app suddenly decides to add a new class of users (we'll call them "newbies") and you forget to update those rules?
With the first set of rules, that new group of users wouldn't be able to post anything, but with the second set of rules, that new group of users would be able to post whatever they want. Either case could be bad if it's not what you intended, but that latter case could be a whole lot worse from a security standpoint.
Of course, all of this is overlooking one tiny little problem: How were we able to populate those list of users in the first place?
Well, let's assume, for a moment, that a user is somehow able to get a list of their friends through the app. (And we'll leave that as an exercise for the reader.) There are a few options we can consider for adding new users to a group chat.
Frankly, any of these would work; it's really up to the app developer to decide what's the best user experience for their app.
So let's look at these in order.
To handle that first option, we'd need to set a rule that says "People who are already in the members list are allowed to write to the members list."
This is pretty similar to the rules we've already set up for posting to the members list:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).exists()", ".write": "data.child(auth.uid).exists()" } } }
Essentially, we're saying that any user can read or write to the members list as long as the user's current user id already exists somewhere in that list.
Restricting this to just letting an owner write to the list is also easy.
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner'" } } }
We're saying, "You can go ahead and write to the members branch of a chat, but only if your userID is already in there and you're listed as an owner."
So we've got that second case covered.
So what about the idea of allowing users to ask to join and then having the owner if the chat approve them? Well, for that, one good option would be to add a pending list in the database alongside the members list, where people could add themselves.
The group's owner would then be allowed to add these potential users to the members list and also delete them from the pending list.
So the first rule we want to declare is, "You can add an entry to the pending list, but only if you're adding yourself." In other words, the key of the item that's being added has to be your own user id.
The rule for adding this looks like the following:
"chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner'" }, "pending": { "$uid": { ".write": "$uid === auth.uid" } } } }
Here, we're saying, "Go ahead and write anything you want to the pending/ branch, just as long as uid is your own userID."
pending/
If we want to be thorough, we can also specify that you can only do this if you haven't added yourself already to the "pending" list, which would look a little more like this:
"pending": { "$uid": { ".write": "$uid === auth.uid && !data.exists()" } }
While we're at it, let's also specify that you can't ask to add yourself if you're already a member of the chat. That would be kinda pointless. So we'd end up with rules like this:
"pending": { "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } }
Then we can add some rules to the overall pending folder that says an owner can read or write to it.
pending
"pending": { ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'", ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'", "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } }
And… that's about it! Assuming we've kept the rules from the previous section that only allow the owner of a chat to read or write to a members list, we've successfully added the security rules that allows an owner to remove an entry from the pending list, and add them to the members list.
Oh, but I guess we've forgotten one last, kinda-important rule: Allowing somebody to create a new group chat. How can we set that up? Well, if you think about it, you can add this by stating that "Anybody can write to a members list if that list is empty and you're setting yourself as the owner"
"members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')" }
For the purpose of this write, imagine you first start by writing to /chats/chat_345/members with an object of { "user_zzz" : "owner" }. That newData line is going to look at this object and make sure the child with the key of the signed in user (user_zzz) is listed as owner.
/chats/chat_345/members
{ "user_zzz" : "owner" }
(user_zzz)
Once you've done this, then you can go ahead and add whatever additional messages or users the owner wants. Since they are now officially listed as an owner, the security rules should allow those actions no problem.
Note that the security rules don't really have a concept of a separate "create directory" action. If a user is allowed to write to chat_456/messages/abc, that rule applies whether or not messages already exists. (Or, for that matter, chat_456.)
chat_456/messages/abc
chat_456
I am not a Firebase security expert, but I'm able to play one in blog posts. Mostly by running the rules simulator.
See, every time you make a change to the rules -- and before you publish them -- you can test out how they run by simulating reads or writes to the database. In the Rules section of the Firebase console, there's a "Simulator" button on the upper right that you can click on. This will bring up a form that allows you to test any kind of read or write action you'd like.
In this example, I'm testing out that last rule by having a user signed in as "user_zzz" attempting to add themselves as an owner to an empty /chats/chat_987/members list. The rules simulator is telling me this is allowed and highlighting the line where the write action evaluates to true.
"user_zzz"
/chats/chat_987/members
(Technically, it's highlighting the wrong line. It's the part of the rule in step 13 that evaluates to true. I think the highlighter doesn't handle line breaks within a string particularly well.)
On the other hand, if that user attempts to add themselves as an owner of a non-empty list, it fails, which is exactly what we want.
Note that there are a few other refinements that could be make here. Right now, we're set up such that owners can add other members as owners. That may or may not be what we want.
Come to think of it, we haven't done anything to validate that new members are being added with legitimate roles. And there's certainly some validation rules we could be adding to the chat messages to make sure they're of a length that our UI can handle. But perhaps this is an area you could play around with.
Copy-and-paste these final rules into your own version of a chat app and see what you can do to add these refinements.
{ "rules": { "chats": { "$chatID": { "messages": { ".read": "data.parent().child('members').child(auth.uid).exists()", ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'" }, "members": { ".read": "data.child(auth.uid).val() == 'owner'", ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')" }, "pending": { ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'", ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'", "$uid": { ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()" } } } } } }
You're more than welcome to check out the documentation if you need more help, and play around a little with the simulator! I guarantee it'll the most fun you'll have this week playing with a database security rules simulator. Or at least in the top three.