DEV Community

Sergio Andrés Jaime Sierra
Sergio Andrés Jaime Sierra

Posted on

Chapter 3: Let's get into Fauna: a guide to understanding Fauna while creating a social media database

In chapter 1, we created a database using the Fauna and Next.js tutorials together, we created some users and logged them in using a project from Next.js.
In chapter 2, we added functionality to follow and post as well as the necessary permissions to do it. Now you have a repository to clone and use it for this purpose.

In this chapter we are going to make use of Fauna’s access control, ABAC (Attribute Based Access Control) to limit what users are allowed to do. For example, they will only be able to create, edit or delete posts if they are the owners. We will create private content and reject access to vip. Also, upper roles and configure post's permissions.

What is ABAC?

We already mentioned what ABAC stands for, but what does it mean? Fauna can access a specific document and the user trying to access it, with this information it can determine if the user trying to access/change the document actually has the permissions to do it. This can help a lot to keep a portion of the user’s information private or prevent changes in a document (e.g. a post) which doesn’t belong to the user trying to change it.

ABAC is composed of two sections: The first one is the Membership, we did already do something about membership in our second chapter: you created a Lambda function that checks the documents in a collection, if the Lambda function returns true, the document has this role.

Let’s use the Fauna’s Dashboard and navigate to Security/Manage Roles/basicUser/Membership

If you followed through the second chapter, you can see the Users collection and should be able to expand it to see a Lambda function. This function has “ref” as a parameter and returns true every time. This means all the users from the Users collection will have this permissions schema. This Lambda function can be changed to include any attribute relatable to the Users collection. For example, the default Lambda function when you are creating a membership goes like this:

Lambda("ref", Select(["data", "vip"], Get(Var("ref"))))
Enter fullscreen mode Exit fullscreen mode

This function uses Get (reads) on the “ref” and then Select(s) the document path data / vip. Here, vip is supposed to contain a boolean stating if the user has a vip (premium) status. You can also check if the user is in a secondary list, like another collection where you can find the references of the admins. Here, we will go through some examples and apply them.

Example 1: Premium content:

Let’s think about this scenario: You don’t have a social network but some premium content your users will be able to see after paying a fee, a lifetime subscription to a service.

  1. Create a collection called PaidVideos.
CreateCollection({name:'PaidVideos'})
Enter fullscreen mode Exit fullscreen mode
  1. Create some documents on it with a field called video:
Map(
  [
    {video:"dQw4w9WgXcQ",text:"Old but gold"},
    {video:"XcgoZO-p9tI",text:"Secret of the forest"},
    {video:"D5q094yRsbA",text:"Tyrano lair"},
    {video:"re0A23CSvpw",text:"Imp’s song"},
    {video:"cM4kqL13jGM",text:"Rebirth of slick"}
  ],
  Lambda(
    "docPayload",
    Create(Collection('PaidVideos'),{data:Var('docPayload')})
  )
)
Enter fullscreen mode Exit fullscreen mode
  1. Create a new function called premiumContent with the following body
Query(
  Lambda(
    [],
    Map(
      Paginate(Documents(Collection("PaidVideos"))),
      Lambda("videoRef", Select("data",Get(Var("videoRef"))))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

You can see there’s a new method called Documents. This method returns a set containing all the documents of the collection in the argument.

Now, let’s create a new role, head to Security/Manage Roles and press on New Role.

Dashboard: Head to security/ manage roles

This new role will require the collection PaidVideos, we will grant view permissions, also, the function premiumContent, we will grant call permissions.

Dashboard: Create new role and add permissions

Why only these two permissions? As you may remember, any document in Users will have the basicUser’s permissions. Their predicate function always returns true. Any document in Users who also has a field called vip with a value of true will have the basicUser’s permissions as well as the premiumUser’s.

Now, head to the Membership tab, add the collection Users and use the function provided by Fauna.

Dashboard: Add membership predicate as recommended by Fauna

Put a name on your new role, I used premiumUser, you can pick any name you like, but we will use this name here when referring to this role.

Take one or two of your existing users and Update them to have a new field vip:true, this will enable them as premiumUser

Dashboard: Update user to have a field called VIP and value true

If you cloned this repository at the beginning of the lesson, you can switch to the branch called third-chapter-end to update your repository to test this functionality.

Log in with any user valid to premiumUser (the ones we updated to have vip:true), click on the new tab labeled premium.

Navigation: Premium view when user is vip

If the user has the attribute vip set to true, it will be able to access the content inside the PaidVideos collection, otherwise, the function will throw an error stating the user doesn’t have permissions to access these documents.

In this case, we handled the error from the API, we can also handle it from the front end to get a 403 status, indicating further payment is required.

Example 2: Monthly subscription

This scenario is very similar to the previous one, except your subscription expires after some time.

Create a new role with the same permissions as premiumUser, in this case, we will name it subscribedUser. Head to the Membership tab, add the Users collection and add this function to the predicate function:

Lambda(
  "ref",
  Let(
    {
      subExpiration: Select(
        ["data", "expiration"],
        Get(Var("ref")),
        TimeSubtract(Now(),1,"day")
      ),
      remainingTime: TimeDiff(Var("subExpiration"), Now(), "seconds")
    },
    GTE(Var("remainingTime"),0)
  )
)
Enter fullscreen mode Exit fullscreen mode

This function seems a little more complex, but don’t panic. We use the method Let to create 2 variables: subExpiration which Get(s) the User info, Select(s) the path data/expiration which will be a timestamp with the expiration date of the subscription (the third argument means if there’s no data at that value, return the current time but yesterday); as well as remainingTime, which subtracts (using TimeDiff) the current time to the time stored on subExpiration and returns the value in seconds. Now, GTE returns true if the variable remainingTime is greater or equal to 0, which means the user still has time on its subscription.

As you can see, this status can change if the user’s subscription expires during the day. When the User’s expiration date has passed, it will no longer have the attributes to be a subscribedUser. Thus, when the user requests the PaidVideos, it will get a “permission denied” response.

Let’s update any non-vip user to have an expiration date for tomorrow.

Dashboard: Update expiration date for tomorrow

When updating a file on the dashboard, if you use the method TimeAdd (or any other method), Fauna will update the field to the result of such method instead of a fixed value.

Let’s login with our updated user and head to the premium tab of our Next.js project. You should see the content we defined as PaidVideos.

If you try with a non-vip, non-subscribed user, you should see a message stating you are not premium

Example 3: Admins only

Let’s suppose your website has a section for admins, managers or any other role which is only granted to some handpicked users.

Create a new collection and name it UpperRoles. Create a new index called roles_by_user_id by using the next command on the Fauna’s shell:

CreateIndex(
  {
    name: "roles_by_user_id",
    unique: true,
    serialized: true,
    source: Collection("UpperRoles"),
    terms: [
      {
        field: ["data", "userId"]
      }
    ],
    values: [
      {
        field: ["data", "roles"]
      }
    ]
  }
)
Enter fullscreen mode Exit fullscreen mode

We mark unique as true to have a simple user in the collection with all the roles assigned to it.

Create a new role with the same permissions we had on premiumUser and subscribedUser, now, let’s head again to the membership tab, select the Users collection and add this as predicate function:

Lambda(
  "ref", 
  Let(
    {
      rolesPage:Paginate(Match(Index('roles_by_user_id'),Var("ref"))),
      roles:Select(["data"], Var("rolesPage"),[]),
    },
    IsNonEmpty(Intersection(["admin"],Var("roles")))
  )
)
Enter fullscreen mode Exit fullscreen mode

We bring the results of the index we just created, it’s expected to bring a single result as it has the unique flag marked as true, on roles we bring the first result of the page and set the default as an empty array, we expect roles to be an array of all the roles a user has available. Finally, we get the Intersection of our roles array and an array containing the role “admin”. If it’s a non-empty array, the user will have this role’s privileges.

Now, let’s grant a user these admin privileges:

Copy the ref of any non-premium, non-subscribed user.

Dashboard: Go to users collection and copy the reference for an user

Create a new document on the UpperRoles collection with the following data:

Create(
  Collection("UpperRoles"),
  {data:{
    userId:Ref(Collection("Users"), "277425124024517138"), //The reference you just copied
    Roles:["admin","accountant","manager"] //additional roles as reference
  }}
)
Enter fullscreen mode Exit fullscreen mode

As you can see, this user will have some roles, including “admin”, which is the value we will look for.

Login with the user you’ve set up and try to access the premium tab. This user is now an admin.

We’ve explored some scenarios to define the role of a user based on its attributes. Next, we are going to determine if a user has access to read/update/create/delete a document.

In chapter two, we set the access to posts in a way that allowed any basicUser to change any document in the collection Posts, to create and delete any document in the collection Followers as well as many other privileges that give way too much freedom and may cause undesired behaviour.

Let’s head to the Security section, click on manage roles, find the basicUser and click the cogwheel on the right side. Let’s click the Users collection to expand it. Look at the </> symbols below each action. When clicked, it allows us to create a predicate function or script to grant privileges.

Dashboard: Predicate functions on privileges

When you click any of them, Fauna provides a simple template script to hint you into a useful function.

If you don’t want to use a script but you already clicked on the button, just find the clear option on the lower right part of the script area.

Let’s expand the Posts collection and see what we can do regarding write permissions.

Dashboard: Default predicate function for write privilege

When writing a document, Fauna’s ABAC calls this function with 3 arguments: the previous document (olData), the document’s future state (newData) and the document’s id (usually ref). Let’s check what is new here, the Equals method compares the arguments inside it and returns true if all of them are equal. The And method returns true if all the arguments are true, just like a regular AND logical gate.

In this example, we check if the document belongs to the user trying to modify it by using Equals:

Equals(Identity(), Select(["data", "owner"], Var("oldData")))
Enter fullscreen mode Exit fullscreen mode

As you can see, it checks the path data/owner in the previous document and compares it with the Identity of the logged in user, which means you can only edit the posts you own. Also, we want the data to remain of the same user, so we check the field in data/owner in both, previous and new, documents to check if the owner will remain the same.

As both Equals methods are inside an And method, both have to return true to confirm the document write. You can also add another field, for example, the creation date of the previous document must be equal to the new date.

Equals(
  Select(["data", "date"], Var("oldData")),
  Select(["data", "date"], Var("newData"))
)
Enter fullscreen mode Exit fullscreen mode

If the function returns true, the document will be updated as if the user had full permissions to do it, otherwise, it will throw an error and the document will remain unchanged.

Note: newData contains the whole document’s new state. If you modify a single field, newData will contain the whole document with the change on the modified field. There’s no need to send the fields you want ABAC to compare.

This is very useful to keep some fields of the database static, for example, the owner of a post. For now, uncomment the functional part of the script so we use it, then, click on the </> symbol under the Create action.

Dashboard: Default predicate function for create privileges

You can see the function here is very similar to the other one, except we only have one argument on the Lambda function which is values, these are the values that are about to be written on the database. The path data/owner has to be equal to the Identity to allow a user to create a post, otherwise, no document is created at all. Let’s uncomment this function as well and check on the Delete action.

Dashboard: Default predicate function for delete privileges

This function gets the document’s id as argument and names it ref, It performs a Get method on the argument and checks the path data/owner to compare it with the Identity. If it’s the owner who’s deleting the post, the action is performed. Let’s uncomment this functional part as well and scroll down to save our changes.

Let’s try to create a post under the regular method. Navigate to chrome, create a post and you should see nothing has changed since we did it in the previous chapter.

Now, let’s break our application:
Copy the ref of any user different from the one you’re logged in, just like we did for the upper roles. Go to the functions section, select the createPost function and change the field on the path data/owner to look like this:

Query(
  Lambda(
    "description",
    Create(Collection("Posts"), {
      data: {
        description: Var("description"),
        date: Now(),
        owner: Ref(Collection("Users"), "277945843461390867"), // The ref you just copied
        likes: 0,
        comments: 0
      }
    })
  )
)

Enter fullscreen mode Exit fullscreen mode

As the reference in the field owner is different from our logged in user, our permission will be denied. Save the broken function and try creating a post again.

Shell: Permission denied error

This error message is quite large, but the punchline is in the responseRaw field (also, you can catch the field responseContent.errors), you will find the reason for the error is “permission denied” and the description states you don’t have the permissions for the action. This is the error you will find every time you try to perform an action you’re not allowed to. This is not the expected behaviour of your app, but a failsafe in case someone tries to break havoc in your app. Now you can repair the broken function, we have tested what we wanted.

Debugging ABAC

Well, we’ve set up some permissions and we want to know if the functions we defined are actually doing what we need them. We will use the Fauna Shell to compare our results with our expectations.

For example, let’s bring our predicate function for premiumUser:

Lambda(
  "ref",
  Select(
    ["data", "vip"],
    Get(Var("ref"))
  )
)
Enter fullscreen mode Exit fullscreen mode

The variable ref will be the user’s ref. So, let’s head to the Fauna’s shell, use the Let method to bring a variable with the name ref.

Let(
  {
    ref:Ref(Collection("Users"),"277945843461390867")
  },
  Select(
    ["data", "vip"],
    Get(Var("ref"))
  )
)
Enter fullscreen mode Exit fullscreen mode

We changed the Lambda method for a Let, and created the variable ref with the reference of a user. In this case, this is the user’s document:

{
  "ref": Ref(Collection("Users"), "277945843461390867"),
  "ts": 1603515727810000,
  "data": {
    "email": "testmail5@mail.com",
    "posts": 0,
    "activeSince": Time("2020-09-28T21:31:02.124870Z"),
    "vip": true
  }
}
Enter fullscreen mode Exit fullscreen mode

When you execute on the shell, you’ll realize that getting the document and selecting the value in the path data/vip will return true.

When you try with another user, for example this:

{
  "ref": Ref(Collection("Users"), "280324497574199812"),
  "ts": 1603600132565000,
  "data": {
    "email": "testmail4@mail.com",
    "posts": 0,
    "activeSince": Time("2020-10-25T03:38:43.365515Z"),
    "expiration": Time("2020-10-26T04:28:52.453007Z"),
    "vip":false
  }
}

Enter fullscreen mode Exit fullscreen mode

The function will return false. Meaning the user will not be included in the premiumUser role.

The only way an ABAC function grants privileges or includes a document within a role is by having the predicate function return true, having a function that returns an error will deny the privileges or the role. This means that you can have users that don’t contain the field vip and this won’t break the functionality of ABAC.

Now, let’s try with the predicate functions to update a post:

Lambda(
  ["oldData", "newData"],
  And(
    Equals(Identity(), Select(["data", "owner"], Var("oldData"))),
    Equals(
      Select(["data", "owner"], Var("oldData")),
      Select(["data", "owner"], Var("newData"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

This one requires the definition of 3 variables: oldData, newData and the user’s id which will replace the Identity method, this is because Fauna's Shell has no identity nor document associated.

Copy and paste the whole existing document for the oldData, do the same for the newData, but change the owner to some other user id (or just something random, it doesn’t matter). When executed on the Fauna shell, you’ll see this returns false because the new value for the owner is not Equal to the previous one.

Let(
  {
    oldData:{
      "ref": Ref(Collection("Posts"), "280597810560107014"),
      "ts": 1603857775247000,
      "data": {
        "description": "I like turtles",
        "date": Time("2020-10-28T04:02:55.038172Z"),
        "owner": Ref(Collection("Users"), "277425124024517138"),
        "likes": 0,
        "comments": 0
      }
    }, 
    newData:{
      "ref": Ref(Collection("Posts"), "280597810560107014"),
      "ts": 1603857775247000,
      "data": {
        "description": "I like turtles",
        "date": Time("2020-10-28T04:02:55.038172Z"),
        "owner": Ref(Collection("Users"), "280324497574199812"),
        "likes": 0,
        "comments": 0
      }
    },
    userId:Ref(Collection("Users"), "277425124024517138")
  },
  And(
    Equals(Var("userId"), Select(["data", "owner"], Var("oldData"))),
    Equals(
      Select(["data", "owner"], Var("oldData")),
      Select(["data", "owner"], Var("newData"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

The reason we copied the whole document instead of just the path we needed is to show you how ABAC will see the information when you are trying to perform the write action on a document. Something similar will happen when you try to read/create/delete a document in this collection due to the predicate functions.

This is basically it, copy the functional part of the Lambda within a Let and set the expected (and some unexpected) values as the Let definitions, with this, you will be able to predict the behaviour of any predicate function you declare.

Password reset for your users

Let’s think about this common scenario: One of your users doesn’t remember the password used for signup. How do you recover it? Fauna won’t show you the password or allow you to see the user’s login keys. Even if you are an admin. However, Fauna allows admins to create Login tokens for any user, no passwords required. This way, you may try to send the user’s token via email or any other confirmation method defined prior to the password loss.

We are going to create a function on Fauna to perform this action. We are going to receive the user’s email, look for it on our database to get the user’s id, create the token and return it to the API, we are expecting this API won’t return the token to the user directly, instead, the API will send an email to the user.

Query(
  Lambda(
    "email",
    Let(
      {
        userId: Select(
          ["data", 0],
          Paginate(
              Match(Index("users_by_email"), Var("email")),
          )
        ),
        returnData: Create(Tokens(), {
          instance: Var("userId"),
          data: { message: "you can add some information here" },
          ttl: TimeAdd(Now(), 5, "minutes") // add time to live
        })
      },
      Var("returnData")
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

We use this function to create a new document in the collection Tokens(), this is the collection where Fauna stores the Login tokens for all Users, this information is partially visible, we will not be able to see the current key nor the password used, but we can see the instance, which should be the user’s id, and the data field, which we used to store a message. We also added a ttl or time to live, this works as an expiration date, so the user has a limited time to reset the password with this token.

Last function on Fauna is resetPassword, this function will update the user’s password to the one provided on the parameters.

Query(
  Lambda(
    "password",
    Do(
      Update(Identity(), { credentials: { password: Var("password") } }),
      Logout(false)
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

As this will update the own user, we need to add privileges to the Users collection to update itself. Add this as the predicate function under the Write action.

Lambda(
  ["oldData"],
  Equals(Identity(), Select("ref", Var("oldData")))
)
Enter fullscreen mode Exit fullscreen mode

Also, add the resetPassword function to the privileges and check the privilege to Call the function.

In our repository, we added a tab called Recover, sign up with a reachable email address and try to reset your password.

Navigation: Tab called forgot takes you to the password recover view

You will get an email similar to this:

Navigation: The email you receive to recover your passsword

And when you click on the link, you’ll be here

Navigation: Reset password by clicking on email

Add a new password and you’ll be able to login with it.

Are you test launching now? Here some advice:

When setting up your environment variables on an actual server, it’s recommended you don’t use a key with administrator or server privileges. Using a key with minimum privileges may keep the functionality intact and your application will be safer.

In Our case, we can have permissions to create and read on the Users collection, add read privileges to the index users_by_email, the function signupUsers and recoverPassword will have call permissions.

With this, you will have a public role with limited functionality, create a key for this role, you don’t need to add a collection or a predicate function, just add the key from the security menu.

Dashboard: Creating public key

And that’s it. Add some styles to make it look fancy, add some features to make it more interesting. It’s up to you.

Thank you so much for following this blog series, I hope it's useful to your projects or your new interests, perhaps.

Discussion (0)