firebase • firestore • security
Refactoring Firestore Security Rules
by Steve Marx on
My next post in my ongoing series about Firestore was going to be about building the leaderboard for New High Score, but that will have to come in the next post. Here, I have to take you on a little detour about how to refactor security rules.
What’s in a name?
A leaderboard is only fun if it show’s each player’s name on the board. When I added names to the New High Score data model, I ended up adding quite a bit of complexity to my security rules, which were already getting a little unwieldy. Here were the rules at the end of my last post:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /games/{document=**} {
allow read;
allow create: if request.resource.id == request.auth.uid
&& request.resource.data.keys().hasOnly(["score", "lastUpdate"])
&& request.resource.data.lastUpdate == request.time
&& request.resource.data.score == 1;
allow update: if request.resource.id == request.auth.uid
&& request.resource.data.keys().hasOnly(["score", "lastUpdate"])
&& request.resource.data.lastUpdate == request.time
&& request.time >= resource.data.lastUpdate + duration.value(1, "s")
&& request.resource.data.score == resource.data.score + 1;
}
}
}
Notice there’s quite a bit of duplication between the create
and update
rules. Adding a name
field makes things even more complex. There are two new cases to consider:
- Document creation may be via just setting the name. This would happen if someone wanted to enter a name before starting to play. I could disallow this, but it seems like a natural thing to support.
- An update could be just the name, with no corresponding increase in score.
Taking into account these cases, the score
and name
fields are now both optional. If a player starts playing before they enter a name, they will have a score
but no name
. If a player enters a name before starting to play, they will have a name
but no score
.
One final requirement is to validate for the name
field. When present, it should always be a string
and a maximum of 30 characters. (This is an arbitrary limit designed to prevent some forms of abuse.)
Here’s a stab at incorporating all that complexity for just the update
rule. (The create
rule would be largely similar.) This is untested, so apologies for any mistakes:
allow update: if request.resource.id == request.auth.uid
&& request.resource.data.keys().hasOnly(["score", "lastUpdate", "name"])
&& (
!("score" in request.resource.data)
|| request.resource.data.score in
[resource.data.get("score", 0), resource.data.get("score", 0) + 1])
&& (
!("name" in request.resource.data) ||
(request.resource.data.name is string
&& request.resource.data.name.size() <= 30))
&& request.resource.data.lastUpdate == request.time
&& request.time >= resource.data.lastUpdate + duration.value(1, "s");
What an unreadable mess!
Variables and functions to the rescue
Fortunately, Firestore security rules can contain functions, and those functions can have local variables. This allows for some structure and code reuse.
The language they use looks a lot like JavaScript, but don’t be fooled. Security rules use a fairly limited programming language. There are no loops, and there aren’t even conditionals!
Functions consist of just let
statements to create local variables and a single return
statement. Functions can call other functions but not recursively.
Rethinking the rules
Starting from scratch, here’s my take on the actual rules I want to enforce in the game:
- Only a document’s owner can write to it.
- Writes are only allowed once per second.
name
, if present, must be a string of no more than 30 characters.score
implicitly starts at zero and can only be incremented by 1 at a time.lastUpdate
must always be the server timestamp of the last document update.- No other fields are allowed.
Most of the above is the same for both create
and update
. With a little imagination, even the small differences disappear.
Ownership
Ownership in the New High Score data model is simple. Each document ID corresponds to a user ID. Only the user with that ID can write to the document.
Here’s a function that handles this case:
function isOwner() {
return request.auth != null && document == path(request.auth.uid);
}
document
comes from here:
match /games/{document=**} {
It’s just a portion of the path of the document being accessed.
request.resource and resource
Earlier, I used request.resource.id
instead of document
. This is because I was only concerned with writes, where there’s always a request.resource
. But now I want to make isOwner()
flexible enough to handle reading a document, too.
request.resource
is only present for writes, and it represents the state of the document after the incoming changes are applied. This is great for validating the effect of a write, but it doesn’t help with a read.
resource
is present any time the document being accessed already exists. It represents the state of the document before the incoming changes are applied. This works great for validating reads of existing documents, but it doesn’t help if a client tries to look for a non-existent document.
In the case of New High Score, as soon as a user logs in or creates an account, the client subscribes to changes to that user’s document. If it doesn’t exist yet, a security rule that relies on resource
will fail.
Rate limiting
This function handles rate limiting:
function notThrottled() {
return resource == null
|| request.time >= resource.data.lastUpdate + duration.value(1, "s");
}
Notice that this handles two separate cases. If the resource
is null
, that means the document doesn’t yet exist. In that case, the update is okay (not throttled). If the document does exist, then the request time has to be at least one second more than the recorded lastUpdate
.
The rest
This function checks the rest of the rules. To combine create
and update
rules, I’ve restructured the score
rule. In a write, the score must either stay the same or increase by 1. A non-existent score is considered to be 0.
function isValidUpdate() {
let new = request.resource.data;
let newScore = new.get("score", 0);
let newName = new.get("name", "");
return new.keys().hasOnly(["score", "lastUpdate", "name"])
&& newScore in [oldScore(), oldScore()+1]
&& newName is string && newName.size() <= 30
&& new.lastUpdate == request.time;
}
The .get()
function on a Map
returns a given member of the map, or a default value if that member isn’t present.
isValidUpdate()
relies on another function, oldScore()
, to encapsulate the somewhat complex logic of figuring out the old score:
function oldScore() {
return resource == null ? 0 : resource.data.get("score", 0);
}
If the document doesn’t exist, resource
will be null
, so I use the default value of 0
. If the document does exist, this returns either the existing score or a default of 0
if there’s no score yet.
Clearer rules
Using the functions above, I can rewrite the rules themselves:
allow read: if isOwner();
allow write: if isOwner() && notThrottled() && isValidUpdate();
Note that write
is the combination of create
and update
, so both types of writes will be validated with the same expression.
The refactoring I did is useful in the same way most refactoring is:
- I reduced code duplication by factoring common logic out into functions.
- Each function has a clear purpose and can be reviewed independently.
- The new rules state clearly what they’re enforcing. These can be easily compared to a specification to ensure that the rules are what was intended.
Full source code
Here’s the full firebase.rules
file with the refactored security rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /games/{document=**} {
function isOwner() {
return request.auth != null && document == path(request.auth.uid);
}
function notThrottled() {
return resource == null
|| request.time >= resource.data.lastUpdate
+ duration.value(1, "s");
}
function oldScore() {
return resource == null ? 0 : resource.data.get("score", 0);
}
function isValidUpdate() {
let new = request.resource.data;
let newScore = new.get("score", 0);
let newName = new.get("name", "");
return new.keys().hasOnly(["score", "lastUpdate", "name"])
&& newScore in [oldScore(), oldScore()+1]
&& newName is string && newName.size() <= 30
&& new.lastUpdate == request.time;
}
allow get: if isOwner();
allow write: if isOwner() && notThrottled() && isValidUpdate();
}
}
}