firebasefirebase-realtime-databasefirebase-authenticationfirebase-security

How to make rules in Firebase Realtime Database that make it possible to securely create or delete "alias"?


How to make it possible to delete the alias if the alias -> id equals to auth.uid, but also allow alias to be created if not exists?

Here are my rules:

{
  "rules": {
    "alias": {
      ".read": true,
      "$alias": {
        ".write": "auth != null",
        ".validate": "!root.child('alias/' + $alias).exists() || root.child('alias/' + $alias).child('id').val() == auth.uid"
      }
    }
}

Here is the shape of my database:

{
  "alias": {
    "mads": {
      "alias": "mads",
      "id": "4SCv1g84PcMoPTtwZloHol0m2j92"
    },
    // ...
  }
}

Solution

  • When working with security rules, it helps to break it down into what operations you want to perform.

    In your case, you want to:

    To bind an alias with the current user, you correctly identified that alias/$alias/id must match the current user.

    However, using root will not achieve this in the way you currently expect. root is a RuleDataSnapshot corresponding to the current data at the root of your Firebase Realtime Database (ref) - i.e. This is the data already in your database before the new data is written. In your .validate rule, you do handle this case with !root.child('alias/' + $alias).exists(), but you aren't asserting that the alias is bound to the right account - just that it didn't already exist. The second part of your rule, root.child('alias/' + $alias).child('id').val() == auth.uid will only return true if the current user is trying to write the data to an alias they already own.

    When interacting with data in the current location in security rules, you should use data (the previous state of the database, and in this case, equal to root.child('alias/' + $alias)), along with newData (the new state of the database, after the write is completed).


    Note: For all snippets that follow, only the "$alias" portion is shown. It must be inserted into the proper rule hierarchy to function

    {
      "rules": {
        "alias": {
          ".read": true,
          // ... insert "$alias" part here ...
        }
    }
    

    To "Bind an alias with the current user, only if it is not bound to someone else.", you would use:

    {
      "$alias": {
        // user must be signed in, alias is being created (does not already exist), bound ID must match the user's ID.
        ".write": "auth != null && !data.exists() && newData.child('id').val() == auth.uid"
      }
    }
    

    To "Allow a user to delete their alias, only if it is bound to their account.", you would use:

    {
      "$alias": {
        // user must be signed in, alias is being deleted (will no longer exist), the currently bound ID of the alias must match the user's ID.
        ".write": "auth != null && !newData.exists() && data.child('id').val() == auth.uid"
      }
    }
    

    You may run into an edge case where a user tries to delete an alias that doesn't actually exist. This will normally happen when a user has a poor connection to your database. To allow this without throwing the permission error, you would use:

    {
      "$alias": {
        // user must be signed in and is attempting to delete an alias that never existed
        ".write": "auth != null && !data.exists() && !newData.exists()"
      }
    }
    

    To combine these together, you would now use:

    {
      "$alias": {
        // user must be signed in AND any of the following are true:
        // - a deletion request has been received for an alias that already doesn't exist
        // - the alias is being created AND the bound ID matches the user's ID.
        // - the alias is being deleted AND the currently bound ID matches the user's ID.
        ".write": "auth != null && ((!data.exists() && !newData.exists()) || (!data.exists() && newData.child('id').val() == auth.uid) || (!newData.exists() && data.child('id').val() == auth.uid))"
      }
    }
    

    The above rules make sure that only create OR delete alias operations are permitted. However, we need to make sure that the data contained in the alias when it exists has both a "id" and "alias" property set to the correct values.

    {
      "$alias": {
        // user must be signed in AND any of the following are true:
        // - a deletion request has been received for an alias that already doesn't exist
        // - the alias is being created AND the bound ID matches the user's ID.
        // - the alias is being deleted AND the currently bound ID matches the user's ID.
        ".write": "auth != null && ((!data.exists() && !newData.exists()) || (!data.exists() && newData.child('id').val() == auth.uid) || (!newData.exists() && data.child('id').val() == auth.uid))"
    
        // all written entries must have 'alias' and 'id' properties where:
        // - 'id' property must match the current user's ID
        // - 'alias' property must match the current alias
        ".validate": "newData.child('id').val() == auth.uid && newData.child('alias').val() == $alias"
      }
    }
    

    If you must restrict entries under alias/$alias to the shape { id: string, alias: string } and prevent any other nested data, you'd need to adjust your validation rules to the following:

    {
      "$alias": {
        // user must be signed in AND any of the following are true:
        // - a deletion request has been received for an alias that already doesn't exist
        // - the alias is being created AND the bound ID matches the user's ID.
        // - the alias is being deleted AND the currently bound ID matches the user's ID.
        ".write": "auth != null && ((!data.exists() && !newData.exists()) || (!data.exists() && newData.child('id').val() == auth.uid) || (!newData.exists() && data.child('id').val() == auth.uid))"
    
        // all written entries must have 'alias' and 'id' properties
        ".validate": "newData.hasChildren(['alias','id'])",
    
        "id": {
          // written property must match the current user's ID
          ".validate": "newData.val() == auth.uid"
        },
        "alias": {
          // written property must match the current alias
          ".validate": "newData.val() == $alias"
        },
        "$other": {
          // no other properties are permitted
          ".validate": "false"
        }
      }
    }