Firebase Realtime Database Security Rules #8 - Deletion of Data

So we have a ruleset now that prevents writes when data already exists at that location in the database. Now we need to think about deletion at that location.

Here is the code for deletion:

DatabaseReference dbRef1 = FirebaseDatabase.getInstance().getReference()
       .child("Levels")
       .child("Level2")
       .child(mAuth.getUid().toString());

dbRef1.removeValue().addOnSuccessListener(new OnSuccessListener<Void>() {
   @Override
   public void onSuccess(Void aVoid) {
       Log.i(TAG, "Value removed from database successfully!");
   }
}).addOnFailureListener(new OnFailureListener() {
   @Override
   public void onFailure(@NonNull Exception e) {
       Log.e(TAG, "Database remove failed");
       Log.e(TAG, e.getMessage());
   }
});

And here is the current state of our database where no value exists at /Levels/Level2/1234567890123456789012345678

 "Levels" : {
    "Level1" : {
      "1234567890123456789012345678" : {
        "score" : 18
      }
    }
  }

Let’s try running the delete code with our current set of rules and see what happens.

Unfortunately we get “Permission denied”:

W: setValue at /Levels/Level2/1234567890123456789012345678 failed: DatabaseError: Permission denied
E: Database remove failed
E: Firebase Database error: Permission denied

What is the issue?  The first thing to understand is that a delete or remove operation is a write operation. So we need a write rule that conforms to writing...nothing.

How to fix it?  Well for delete we will use a combination of the newData and data variables. Let’s sound it out:

So far users can write data to /Levels/Level2/<uid> as long as 

  • they are logged in

  • they are the owner of the path

  • they got a score higher than 15 in Level1

  • no score for Level2 exists already

  • a score field exists in the data being written

And we need to amend our rules to capture the following security criteria:

  • Either no data exists at that location in the database already or the new data being written is empty:   (!data.exists() || !newData.exists())

  • No data is being written or if data is being written then there must be a “score” field:  !newData.exists() || newData.child('score').exists())

And, oh hey, remember that we have a validation rule on required fields in the object?  We’ll need to update the .validate rule to check for when there is no new data being passed:

 ".validate" : "!newData.exists() || newData.hasChildren(['score'])",

Here is our new .write rule:

".write": "auth.uid != null && auth.uid == $userUID && root.child('Levels').child('Level1').child(auth.uid).child('score').val() > 15 && (!data.exists() || !newData.exists()) && (!newData.exists() || newData.child('score').exists())",

Here is our new .validate rule:

 ".validate" : "!newData.exists() || newData.hasChildren(['score'])",

Ready to test our new rules?

We’re starting the simulation testing with no existing data at /Levels/Level2/1234567890123456789012345678.

We’ll test writing a Level2 score of “20”. So our Data (JSON) field in the Simulator looks like:

{
    "score" : 20
}

Simulate “set” operation: it works!

Screen Shot 2019-07-06 at 12.02.48 PM.png

Simulate “update” operation: it works!

Screen Shot 2019-07-06 at 12.03.06 PM.png

Notice that the green success banner indicates which operation you tested, set or update.

Now to test a simulated delete we want to pass a Data JSON object that has no data. To simulate “no data” we pass an empty set of curly braces:

{}

(If you have no data at all you’ll notice you’ll get an “Invalid JSON” error.)

Simulate set operation:  it works! (NB: the simulator today gives me a successful message but the .validate rule gets a red X. This looks like a bug to me since running the real code allows it. I submitted it to Google for review.)

Simulate update:  notice that you don’t get a result. The Simulator is saying, hey, you’re running an update in a database location where there is no existing data and you’re not passing any data. So there’s no work to do here.

Ok!  We are halfway done testing our rules.  Now we need to test them when data exists at this location in the database.  

Run the code to create an object at the Level2 location for your user so that now the data looks like:

{
  "Levels" : {
    "Level1" : {
      "1234567890123456789012345678" : {
        "score" : 18
      }
    },
    "Level2" : {
      "1234567890123456789012345678" : {
        "score" : 30
      }
    }
  },

Let’s start our tests by trying to write a score of “20”:

{
    "score" : 20
}

Simulate set: it failed - as expected! This is correct because in order to perform a write at that location in the database no data can exist already

Screen Shot 2019-07-06 at 1.39.45 PM.png

Simulate update: it failed - as expected!

Screen Shot 2019-07-06 at 1.39.30 PM.png

We are almost done. We need to test deleting existing data. Remove the score data being written and pass only empty curly braces in the Data (JSON) field in order to simulate deletion:

{}

Simulate set:  it works!

Simulate update:  it works!

We’re done!

Publish the rules to save them!

Here is our ruleset:

{
  "rules": { 
      "MyUsers": {
          "$userUID": {
          ".read": "auth.uid != null && auth.uid == data.child('userUID').val() == auth.uid",
          ".write": "auth.uid != null && auth.uid == $userUID && newData.child('userUID').val() == auth.uid",  
          ".validate": "newData.hasChildren(['firstName', 'lastName', 'userUID'])",
          "firstName" : {
              ".validate": "newData.isString() && newData.val().length <= 20"
          },
          "lastName" : {
              ".validate": "newData.isString() && newData.val().length <= 20"
          },
          "userUID" : {
              ".validate": "newData.isString() && newData.val().length === 28 && newData.val().matches(/^[A-z0-9]*/)"
          }
        }
    },
      "Levels": {
          "Level1": {
              "$userUID": {
                  ".read": "auth.uid != null && auth.uid == $userUID",
                  ".write": "auth.uid != null && auth.uid == $userUID && root.child('MyUsers').child(auth.uid).exists()",
              ".validate" : "newData.hasChildren(['score'])",
              "score": {
                ".validate" : "newData.isNumber() && newData.val() >=0"
              }
            }
          },
          "Level2": {
              "$userUID": {
                  ".read": "auth.uid != null && auth.uid == $userUID",
                            ".write": "auth.uid != null && auth.uid == $userUID && root.child('Levels').child('Level1').child(auth.uid).child('score').val() > 15 && (!data.exists() || !newData.exists()) && (!newData.exists() || newData.child('score').exists())",
              ".validate" : "!newData.exists() || newData.hasChildren(['score'])",
              "score": {
                ".validate" : "newData.isNumber() && newData.val() >=0"
              }
            }
          }
      }
  }
}