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"))))
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.
- Create a collection called PaidVideos.
CreateCollection({name:'PaidVideos'})
- 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')})
)
)
- Create a new function called premiumContent with the following body
Query(
Lambda(
[],
Map(
Paginate(Documents(Collection("PaidVideos"))),
Lambda("videoRef", Select("data",Get(Var("videoRef"))))
)
)
)
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.
This new role will require the collection PaidVideos, we will grant view permissions, also, the function premiumContent
, we will grant call 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.
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
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.
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)
)
)
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.
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"]
}
]
}
)
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")))
)
)
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.
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
}}
)
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.
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.
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")))
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"))
)
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.
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.
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
}
})
)
)
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.
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"))
)
)
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"))
)
)
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
}
}
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
}
}
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"))
)
)
)
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"))
)
)
)
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")
)
)
)
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)
)
)
)
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")))
)
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.
You will get an email similar to this:
And when you click on the link, you’ll be here
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.
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.
Top comments (0)