Protect your Firestore data with Firebase Security Rules

1. Before you begin

Cloud Firestore, Cloud Storage for Firebase, and the Realtime Database rely on configuration files you write to grant read and write access. That configuration, called Security Rules, can also act as a kind of schema for your app. It's one of the most important parts of developing your application. And this codelab will walk you through it.

Prerequisites

  • A simple editor such as Visual Studio Code, Atom, or Sublime Text
  • Node.js 8.6.0 or higher (to install Node.js, use nvm; to check your version, run node --version)
  • Java 7 or higher (to install Java use these instructions; to check your version, run java -version)

What you'll do

In this codelab, you will secure a simple blog platform built on Firestore. You will use the Firestore emulator to run unit tests against the Security Rules, and ensure that the rules allow and disallow the access you expect.

You'll learn how to:

  • Grant granular permissions
  • Enforce data and type validations
  • Implement Attribute Based Access Control
  • Grant access based on authentication method
  • Create custom functions
  • Create time-based Security Rules
  • Implement a deny list and soft deletes
  • Understand when to denormalize data to meet multiple access patterns

2. Set up

This is a blogging application. Here's a high level summary of the application functionality:

Draft blog posts:

  • Users can create draft blog posts, which live in the drafts collection.
  • The author can continue to update a draft until it's ready to be published.
  • When it's ready to be published, a Firebase Function is triggered that creates a new document in the published collection.
  • Drafts can be deleted by the author or by site moderators

Published blog posts:

  • Published posts can't be created by users, only via a function.
  • They can only be soft-deleted, which updates a visible attribute to false.

Comments

  • Published posts allow comments, which are a subcollection on each published post.
  • To reduce abuse, users must have a verified email address and not be on a denyist in order to leave a comment.
  • Comments can only be updated within an hour after it's posted.
  • Comments can be deleted by the comment author, the author of the original post, or by moderators.

In addition to access rules, you'll create Security Rules that enforce required fields and data validations.

Everything will happen locally, using the Firebase Emulator Suite.

Get the source code

In this codelab, you'll start off with tests for the Security Rules, but mimimal Security Rules themselves, so the first thing you need to do is clone the source to run the tests:

$ git clone https://github.com/FirebaseExtended/codelab-rules.git

Then move into the initial-state directory, where you will work for the remainder of this codelab:

$ cd codelab-rules/initial-state

Now, install the dependencies so you can run the tests. If you're on a slower internet connection this may take a minute or two:

# Move into the functions directory, install dependencies, jump out.
$ cd functions && npm install && cd -

Get the Firebase CLI

The Emulator Suite you'll use to run the tests is part of the Firebase CLI (command-line interface) which can be installed on your machine with the following command:

$ npm install -g firebase-tools

Next, confirm that you have the latest version of the CLI. This codelab should work with version 8.4.0 or higher but later versions include more bug fixes.

$ firebase --version
9.10.2

3. Run the tests

In this section, you'll run the tests locally. This means it is time to boot up the Emulator Suite.

Start the Emulators

The application you'll work with has three main Firestore collections: drafts contain blog posts that are in progress, the published collection contains the blog posts that have been published, and comments are a subcollection on published posts. The repo comes with unit tests for the Security Rules that define the user attributes and other conditions required for a user to create, read, update, and delete documents in drafts, published and comments collections. You'll write the Security Rules to make those tests pass.

To start, your database is locked down: reads and writes to the database are universally denied, and all the tests fail. As you write Security Rules, the tests will pass. To see the tests, open functions/test.js in your editor.

On the command line, start the emulators using emulators:exec and run the tests:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"

Scroll to the top of the output:

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

Right now there are 9 failures. As you build the rules file, you can measure progress by watching more tests pass.

4. Create blog post drafts.

Because the access for draft blog posts is so different from the access for published blog posts, this blogging app stores draft blog posts in a separate collection, /drafts. Drafts can only be accessed by the author or a moderator, and has validations for required and immutable fields.

Opening the firestore.rules file, you'll find a default rules file:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

The match statement, match /{document=**}, is using the ** syntax to recursively apply to all documents in subcollections. And because it's at the top level, right now the same blanket rule applies to all requests, no matter who is making the request or what data they're trying to read or write.

Start by removing the inner-most match statement and replacing it with match /drafts/{draftID}. (Comments of the structure of documents can be helpful in rules, and will be included in this codelab; they're always optional.)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

The first rule you'll write for drafts will control who can create the documents. In this application, drafts can only be created by the person listed as the author. Check that the UID of the person making the request is the same UID listed in the document.

The first condition for the create will be:

request.resource.data.authorUID == request.auth.uid

Next, documents can only be created if they include the three required fields, authorUID,createdAt, and title. (The user doesn't supply the createdAt field; this is enforcing that the app must add it before trying to create a document.) Since you only need to check that the attributes are being created, you can check that request.resource has all those keys:

request.resource.data.keys().hasAll([
  "authorUID",
  "createdAt",
  "title"
])

The final requirement for creating a blog post is that the title can't be more than 50 characters long:

request.resource.data.title.size() < 50

Since all these conditions must be true, concatenate these together with logical AND operator, &&. The first rule becomes:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

In the terminal, rerun the tests and confirm that the first test passes.

5. Update blog post drafts.

Next, as authors refine their draft blog posts, they'll edit the draft documents. Create a rule for the conditions when a post can be updated. First, only the author can update their drafts. Note that here you check the UID that's already written,resource.data.authorUID:

resource.data.authorUID == request.auth.uid

The second requirement for an update is that two attributes, authorUID and createdAt should not change:

request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
]);

And finally, the title should be 50 characters or fewer:

request.resource.data.title.size() < 50;

Since these conditions all need to be met, concatenate them together with &&:

allow update: if
  // User is the author, and
  resource.data.authorUID == request.auth.uid &&
  // `authorUID` and `createdAt` are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
  ]) &&
  // Title must be < 50 characters long
  request.resource.data.title.size() < 50;

The complete rules become:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

Rerun your tests and confirm that another test passes.

6. Delete and read drafts: Attribute Based Access Control

Just as authors can create and update drafts, they can also delete drafts.

resource.data.authorUID == request.auth.uid

Additionally, authors with an isModerator attribute on their auth token are allowed to delete drafts:

request.auth.token.isModerator == true

Since either of these conditions are sufficient for a delete, concatenate them with a logical OR operator, ||:

allow delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

The same conditions apply to reads, so that permission can be added to the rule:

allow read, delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

The full rules are now:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }
  }
}

Rerun your tests and confirm that another test now passes.

7. Reads, creates, and deletes for published posts: denormalizing for different access patterns

Because access patterns for the published posts and draft posts are so different, this app denormalizes the posts into separate draft and published collections. For instance, published posts can be read by anyone but can't be hard hard-deleted, while drafts can be deleted but can only be read by the author and moderators. In this app, when a user wants to publish a draft blog post, a function is triggered that will create the new published post.

Next, you'll write the rules for published posts. The simplest rules to write are that published posts can be read by anyone, and can't be created or deleted by anyone. Add these rules:

match /published/{postID} {
  // `authorUID`: string, required
  // `content`: string, required
  // `publishedAt`: timestamp, required
  // `title`: string, < 50 characters, required
  // `url`: string, required
  // `visible`: boolean, required

  // Can be read by everyone
  allow read: if true;

  // Published posts are created only via functions, never by users
  // No hard deletes; soft deletes update `visible` field.
  allow create, delete: if false;
}

Adding these to the existing rules, the entire rules file becomes:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }

    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;
    }
  }
}

Rerun the tests, and confirm that another test passes.

8. Updating published posts: Custom functions and local variables

The conditions to update a a published post are:

  • it can only be done by the author or moderator, and
  • it must contain all the required fields.

Since you have already written conditions for being an author or a moderator, you could copy and paste the conditions, but over time that could become difficult to read and maintain. Instead, you'll create a custom function that encapsulates the logic for being either an author or moderator. Then, you'll call it from multiple conditions.

Create a custom function

Above the match statement for drafts, create a new function called isAuthorOrModerator that takes as arguments a post document (this will work for either drafts or published posts) and the user's auth object:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {

    }

    match /drafts/{postID} {
      allow create: ...
      allow update: ...
      ...
    }

    match /published/{postID} {
      allow read: ...
      allow create, delete: ...
    }
  }
}

Use local variables

Inside the function, use the let keyword to set isAuthor and isModerator variables. All functions must end with a return statement, and ours will return a boolean indicating if either variable is true:

function isAuthorOrModerator(post, auth) {
  let isAuthor = auth.uid == post.authorUID;
  let isModerator = auth.token.isModerator == true;
  return isAuthor || isModerator;
}

Call the function

Now you'll update the rule for drafts to call that function, being careful to pass in resource.data as the first argument:

  // Draft blog posts
  match /drafts/{draftID} {
    ...
    // Can be deleted by author or moderator
    allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
  }

Now you can write a condition for updating published posts that also uses the new function:

allow update: if isAuthorOrModerator(resource.data, request.auth);

Add validations

Some of the fields of a published post shouldn't be changed, specifically url, authorUID, and publishedAt fields are immutable. The other two fields, title and content, and visible must still be present after an update. Add conditions to enforce these requirements for updates to published posts:

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

Create a custom function on your own

And finally, add a condition that the title be under 50 characters. Because this is reused logic, you could do this by creating a new function, titleIsUnder50Chars. With the new function, the condition for updating a published post becomes:

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

And the complete rule file is:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

Rerun the tests. At this point, you should have 5 passing tests and 4 failing ones.

9. Comments: Subcollections and sign-in provider permissions

The published posts allow comments, and the comments are stored in a subcollection of the published post (/published/{postID}/comments/{commentID}). By default the rules of a collection don't apply to subcollections. You don't want the same rules that apply to the parent document of the published post to apply to the comments; you'll craft different ones.

To write rules for accessing the comments, start with the match statement:

match /published/{postID}/comments/{commentID} {
  // `authorUID`: string, required
  // `comment`: string, < 500 characters, required
  // `createdAt`: timestamp, required
  // `editedAt`: timestamp, optional

Reading comments: Can't be anonymous

For this app, only users that have created a permanent account, not an anonymous account can read the comments. To enforce that rule, look up the sign_in_provider attribute that's on each auth.token object:

allow read: if request.auth.token.firebase.sign_in_provider != "anonymous";

Rerun your tests, and confirm that one more test passes.

Creating comments: Checking a deny list

There are three conditions for creating a comment:

  • a user must have a verified email
  • the comment must be fewer than 500 characters, and
  • they can't be on a list of banned users, which is stored in firestore in the bannedUsers collection. Taking these conditions one at a time:
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

The final rule for creating comments is:

allow create: if
  // User has verified email
  (request.auth.token.email_verified == true) &&
  // UID is not on bannedUsers list
  !(exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

The entire rules file is now:

For bottom of step 9
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));
    }
  }
}

Rerun the tests, and make sure one more test passes.

10. Updating comments: Time-based rules

The business logic for comments is that they can be edited by the comment author for a hour after creation. To implement this, use the createdAt timestamp.

First, to establish that the user is the author:

request.auth.uid == resource.data.authorUID

Next, that the comment was created within the last hour:

(request.time - resource.data.createdAt) < duration.value(1, 'h');

Combining these with the logical AND operator, the rule for updating comments becomes:

allow update: if
  // is author
  request.auth.uid == resource.data.authorUID &&
  // within an hour of comment creation
  (request.time - resource.data.createdAt) < duration.value(1, 'h');

Rerun the tests, and make sure one more test passes.

11. Deleting comments: checking for parent ownership

Comments can be deleted by the comment author, a moderator, or the author of the blog post.

First, because the helper function you added earlier checks for an authorUID field that could exist on either a post or a comment, you can reuse the helper function to check if the user is the author or moderator:

isAuthorOrModerator(resource.data, request.auth)

To check if the user is the blog post author, use a get to look up the post in Firestore:

request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID

Because any of these conditions are sufficient, use a logical OR operator between them:

allow delete: if
  // is comment author or moderator
  isAuthorOrModerator(resource.data, request.auth) ||
  // is blog post author
  request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;

Rerun the tests, and make sure one more test passes.

And the entire rules file is:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

      allow update: if
        // is author
        request.auth.uid == resource.data.authorUID &&
        // within an hour of comment creation
        (request.time - resource.data.createdAt) < duration.value(1, 'h');

      allow delete: if
        // is comment author or moderator
        isAuthorOrModerator(resource.data, request.auth) ||
        // is blog post author
        request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;
    }
  }
}

12. Next steps

Congratulations! You've written the Security Rules that made all the tests pass and secured the application!

Here are some related topics to dive into next:

  • Blog post: How to code review Security Rules
  • Codelab: walking through local first development with the Emulators
  • Video: How to use set up CI for emulator-based tests using GitHub Actions