firebasefirestoresecurity

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.

Comic courtesy of crystallize.com

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();
    }
  }
}

Me. In your inbox?

Admit it. You're intrigued.

Subscribe

Related posts