DEV Community

loading...
Cover image for Setting advanced role-based access patterns in your SPA with Fauna and Auth0
Fauna, Inc.

Setting advanced role-based access patterns in your SPA with Fauna and Auth0

fauna_admin profile image Fauna Originally published at fauna.com ・15 min read

Author: Brecht DeRooms
Date: Nov 23rd, 2020


In the previous part of this tutorial, we set up a Single Page Application (SPA) that authenticates with Auth0 and uses the Auth0 access token to connect with Fauna. Until now, we received access to the whole ‘dinosaurs’ collection when we logged in, regardless of the identity or type of user that was logged in. However, since Auth0 provides you with full control of the content of the Auth0 token and Fauna has the powerful FQL to interpret the token contents, anything is possible. In this follow-up tutorial, we dive into multiple approaches for more advanced role-based access patterns.

Each of these approaches will be a new commit in a separate ‘extras’ branch of the same repository which we have cloned in the previous part of this guide:

git clone git@github.com:fauna-brecht/faunadb-auth-skeleton-frontend-with-auth0.git
Enter fullscreen mode Exit fullscreen mode

To follow along in the code, switch to the extras branch:

git checkout extras
Enter fullscreen mode Exit fullscreen mode

Role-based authorization

There are many ways to implement role-based authorization (all with associated pros and cons) when we combine Auth0 and Fauna. We will look into three approaches from which you can choose the most appropriate for your application.

Roles with Auth0 roles and rules

Since we use Auth0 as our Identity Provider, the users are stored in Auth0 which makes it the perfect place to store our roles as well. In this approach, we’ll define roles in Auth0 and place these roles on the token with a custom rule. We then use Fauna to enforce these roles which provides us with a nice middle ground between the easiness of assigning roles to users in the Auth0 interface and the power and flexibility of Fauna’s ABAC security system which we will use to enforce the role. In essence, we will be mapping Auth0 roles on Fauna roles, the changes we will explain here are all encapsulated in this commit.

Alt Text

1. Define roles for the Auth0 API

First, we need to define roles in Auth0 which we will assign to the end-users of our application.

Alt Text

In our application, we will have three different roles which we will give different permissions:

  • Admin user: can see all dinos without limitation.
  • Normal user: can see all dinos except legendary.
  • Public user: user that is not logged in, can only see common dinos.

2. Assign roles to users

We can then use the Auth0 UI to assign these roles to a specific user.

Alt Text

Once we have defined these roles we need to place that information on the token so that Fauna knows which roles are assigned to the user that logged in and can enforce the correct permissions. To enrich the token with this information, we will use Auth0 rules which are bits of logic that hook in on a certain part of the login process and can alter the user information or access token before it’s sent to the application. To get started, we need to create a new rule.

Alt Text

On the next screen, pick a rule template. You can simply select the Empty rule since we will provide you with the code in the next step.

Alt Text

The code to add the roles to the access token can’t be much simpler since the roles are provided in the rule context --one of the parameters that is automatically passed to the Auth0 rule.

function setRolesToUser(user, context, callback) {
  context.accessToken['https:/db.fauna.com/roles'] = context.authorization.roles;
  return callback(null, user, context);
}
Enter fullscreen mode Exit fullscreen mode

You might wonder why we put the ‘https://db.fauna.com/’ prefix in front of the attribute. Custom claims in Auth0 need to be prefixed with a domain of choice. Once we implemented that rule and made sure it’s enabled. The access token will now contain the role.

{
  "https:/db.fauna.com/roles": [
    "Admin"
  ],
  "iss": "https://Fauna-auth0.auth0.com/",
  "sub": "google-oauth2|107696438605329289272",
  "aud": [
    "https://db.fauna.com/db/yxxeeaaqcydyy",
    "https://Fauna-auth0.auth0.com/userinfo"
  ]
}
Enter fullscreen mode Exit fullscreen mode

3. Conditionally assign roles to the access provider

When we send this token to Fauna, we are now able to use this extra claim however we see fit. We could use the CurrentToken() function to retrieve this attribute and reason with it right in our FQL query. However, there is a more convenient way for this specific use case, we can update the access provider to conditionally assign these roles.

When we created the access provider we only specified one role in the previous example. Instead, we can provide multiple roles and define predicates on these roles to conditionally assign them. A predicate is an FQL Lambda function that takes in the contents of the access token as a parameter. When we run a Fauna query, this predicate will run to determine which roles need to be assigned, the role will be assigned when the predicate function returns true.

We could redefine our access provider as follows to assign a different role to the user depending on the contents of the JWT.

CreateAccessProvider(
 {
    "name": "Auth0",
    "issuer": "https://<auth0 domain>/",
    "jwks_uri": "https://<auth0 domain>/.well-known/jwks.json",
    "roles: [
      {
        role: Role('loggedin_admin'),
        predicate: Query(Lambda('accessToken',
          ContainsValue('Admin', Select(["https:/db.fauna.com/roles"], Var('accessToken')))
        ))
      },
      {
        role: Role('loggedin_normal'),
        predicate: Query(Lambda('accessToken',
          ContainsValue('Normal', Select(["https:/db.fauna.com/roles"], Var('accessToken')))
        ))      
      },
      {
        role: Role('loggedin_public'),
        predicate: Query(Lambda('accessToken',
          ContainsValue('Public', Select(["https:/db.fauna.com/roles"], Var('accessToken')))
        ))    
      }
    ]
  }
)
Enter fullscreen mode Exit fullscreen mode

Note: all these roles and access providers are defined in the extras branch of the repository simply swap out the roles and run npm run setup again to update the access provider and roles to give them a spin.

A bit too verbose? Remember that FQL is super composable, and we can leverage the host language (in our case JavaScript) to assign reusable snippets of FQL to variables. For example, we could improve this as follows:

const AssignRole = (faunaRole, auth0Role) => {
  return {
    role: Role(faunaRole),
    predicate: Query(Lambda('accessToken',
      ContainsValue(auth0Role, Select(["https:/db.fauna.com/roles"], Var('accessToken')))
    ))
  }
}

CreateAccessProvider(
 {
    "name": "Auth0",
    "issuer": "https://<auth0 domain>/",
    "jwks_uri": "https://<auth0 domain>/.well-known/jwks.json",
    "membership: [
      AssignRole('loggedin_normal', 'Normal'),
      AssignRole('loggedin_admin', 'Admin'),
      AssignRole('loggedin_public', 'Public')
    ]
  }
)
Enter fullscreen mode Exit fullscreen mode

Regardless of how you write it, we can now switch the roles via the Auth0 UI and upon a new login, the privileges of the user will correspond to the new role. For example, if we assign a user to the Public role via the Auth0 interface, the ‘Public’ role, a few things will happen. The JWT token will now contain a Public role and Fauna will therefore assign the permissions from the ‘loggedin_public’ role.

Those permissions only allow us to see ‘common’ dinosaurs as defined in the privileges of that role.

const CreateLoggedInRolePublic = CreateOrUpdateRole({
  name: 'loggedin_public',
  privileges: [
    {
      resource: Collection('dinos'),
      actions: {
        read: Query(Lambda(['dinoReference'], 
         Equals(Select(['data', 'rarity'], Get(Var('dinoReference'))), 'common')))
      }
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Therefore, the frontend will only show the two dinosaurs that are common.
Alt Text

Fine-grained permissions with Auth0 roles

We can also manage fine-grained permissions in Auth0 and enforce them with Auth0 roles. In the previous approach, we have encoded these in FQL in the ABAC roles. This brings great power but it might not be exactly what you want. In this approach, we will assign permissions to the roles in Auth0 and enforce these permissions with fine-grained Fauna roles on the Fauna side. The changes to the code can be found in this commit.

Enforcing fine-grained permissions that come in via a JWT token is more complex to enforce in Fauna roles, but once set up, it brings the advantage that you can manage these permissions in the Auth0 user interface without touching code. It’s a trade-off, if you require exotic rules, the previous approach would probably be more suitable, if you want a manager to manage role permissions through a UI, this approach might be more suitable. It also gives you the advantage in Auth0 that you could opt to assign permissions to roles as well as directly assign permissions to specific users.

Alt Text

1. Define permissions in the Auth0 API

We need to specify the permissions in the Auth0 API we have created in one of the previous steps. Auth0 permissions will be added to the JWT token when the API is included as an audience and the user has the correct Auth0 role for these permissions. To start adding permissions, let’s go to the APIs tab, select the correct API and select Permissions.

Alt Text

Permissions are string representations of the permissions that you want to convey to the API. The API will be responsible for interpreting these strings and enforcing the permissions. In this case, the API is Fauna and the enforcement will be done in Fauna ABAC roles. You are free to choose the string representation of your permissions. In the screenshot below, you can see two examples, one to express permissions for a specific type of dinosaur while the second represents read permissions to the whole collection.

Alt Text

While we are configuring the API, make sure to go to the Settings page and check the Enable RBAC and Add Permissions in the Access Token slider. This will make sure that the permissions are added to the token when we assign them to a user.

Alt Text

2. Define roles for the Auth0 API

Similar to the previous approach, we need to create roles which we will be able to assign to our users.

Alt Text

Alt Text

We can then bind these roles to users in Auth0. However, where we have used a rule to place the role on the token in the previous approach, we will now use Auth0 permissions. In this case, we don’t need a custom rule, when a user is assigned to a role and permissions are added to that role, these permissions will automatically appear on the token as we will see.

3. Assign permissions to roles:

While still in the roles view, go to the details view of a specific role to start adding permissions.
Alt Text

Finally, go to the Permissions tab and select the Add Permissions button. We now see why we had to specify permissions on the API before since adding permissions allows you to select a specific API and browse through these API permissions to add them to the role.

Alt Text

4. Assign roles to users

Now we can assign these users to the roles we have created in the Auth0 Users UI. Users that have previously logged in to one of your apps will appear here. If you don’t have users yet, simply try to log in with your social account in your application and you will appear here.

Select the three dots (...) behind the user of choice and select ‘Assign Roles’.

Alt Text

In case you want to avoid having to manually assign roles each time a new user arrives, you can automate this using the Auth0 ‘Management API’ or could write a rule to automatically assign roles (or a default role) to users when a new user arrives in the system.

Now that we have a user assigned to this role, the permissions of this role will be added to the JWT token automatically upon login. In this case, they are added under the permissions attribute instead of as a custom claim.

{
  "iss": "https://Fauna-auth0.auth0.com/",
  "sub": "google-oauth2|107696438605329289272",
  "aud": [
    "https://db.fauna.com/db/yxxeeaaqcydyy",
    "https://Fauna-auth0.auth0.com/userinfo"
  ],
  "iat": 1602770077,
  "exp": 1602856477,
  "azp": "OgU7xmvv7pwumxlbilTA4MB7pErILWfS",
  "scope": "openid profile email",
  "permissions": [
    "read:dinosaurs",
    "read:dinosaurs:common",
    "read:dinosaurs:epic",
    "read:dinosaurs:exotic",
    "read:dinosaurs:legendary",
    "read:dinosaurs:rare",
    "read:dinosaurs:uncommon",
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now that we have these permissions available on the token, we can reason with them in Fauna.

5. Interpret the permissions in Fauna

Now that we have tokens with permissions coming into our application that we can send to Fauna we have to enforce those. Until now, all we are doing is letting Fauna know that a user has certain permissions in the string format we invented (e.g. read:dinosaurs:common). We still have to interpret these strings with a Fauna role or a predicate on the Access Provider.

To provide an alternative approach we will now enforce them on the role by using CurrentToken(). Although this role will be relatively complex, we can again rely on FQL’s composability to specify handy reusable roles.

Debugging tip: testing roles can be hard, it’s often a good idea, when debugging such a role to separate the query contained in the role from the actual role. In that case, replace CurrentToken() with a simple JSON object, (e.g. { permissions: "read:dinosaurs" }) and replace the incoming reference variable (if used in the role) with a reference to a Fauna document and you can verify whether the query returns true or false.

The top level function to verify whether a collection can be read will look as follows.

 CreateOrUpdateRole({
    name: 'loggedin_fine_grained',
    privileges: [
      {
        resource: Collection('dinos'),
        actions: {
          read: Query(Lambda(['dinoRef'], 
              And(HasAccessToCollection('dinosaurs', 'read'))))
        }
      }
    ]
  })
Enter fullscreen mode Exit fullscreen mode

Since we use a ‘HasAccessToCollection’ helper function, the permissions are easy to read. This simple helper function provides read access to our Fauna ‘dinos’ collection if the token contains a permission string that looks like: ‘read:dinosaurs’. A lot is going on behind the scenes of this helper function, from string parsing to enforcing access. All of this is possible in pure FQL but we’ll definitely dive into more advanced queries. In case you are new to FQL, it might be a good idea to follow a crash course to FQL first. If we start at the bottom of the chain, there is a GetActionCollectionType function which transforms the custom string format into an object that is easier to work with.

const GetActionCollectionType = permissionString =>
  Let(
    {
      split: q.FindStrRegex(permissionString, q.Concat(['[^\\', ':', ']+'])),
      action: Select([0, 'data'], Var('split'), false),
      collection: Select([1, 'data'], Var('split'), false),
      dinoType: Select([2, 'data'], Var('split'), false)
    },
    { action: Var('action'), collection: Var('collection'), type: Var('dinoType') }
  )
Enter fullscreen mode Exit fullscreen mode

This function transforms ‘read:dinosaurs:common’ in the following object:

  {
    action: "read",
    collection: "dinosaurs",
    type: "common"
  }
Enter fullscreen mode Exit fullscreen mode

Next, there is a generic helper function that abstracts away some logic which will make it easier for us to create new behavior as we will see.

It loops over the incoming array of permission strings and applies the snippet of FQL that was passed in to see whether it returns true for any of the given permissions. Since FQL is procedural it lends itself well for such logic.

const HasAccessGeneric = Rule => {
  return Any(
    q.Map(
      Select(['permissions'], CurrentToken()),
      Lambda(
        ['permissionString'],
        Let(
          {
            splitString: GetActionCollectionType(Var('permissionString'))
          },
          Rule
        )
      )
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

By having these two convenient functions we can now simply define our has read access rule as follows:

const HasAccessToCollection = (collectionName, accessType) => {
  return HasAccessGeneric(
    And(
      Equals(accessType, Select(['action'], Var('splitString'))),
      Equals(collectionName, Select(['collection'], Var('splitString')))
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

This is, as we have seen, the ‘HasAccessToCollection’ function that we used to express the privileges in our role.

However, we also provided a dinosaur type in the permissions, let’s write a slightly different role based on the same generic function. This addition will make sure that we only get access to a dinosaur of a certain rarity (e.g. "legendary") in case we have the corresponding permission (e.g. "read:dinosaurs:legendary").

...   
actions: {
     read: Query(
        Lambda(
           ['dinoRef'],
           And(
             HasAccessToCollection('dinosaurs', 'read'),
             HasAccessToDinosaurType('dinosaurs', 'read', 
                Select(['data', 'rarity'], Get(Var('dinoRef'))))
           )
        )
      )
 }
Enter fullscreen mode Exit fullscreen mode

The implementation of this new function is easy thanks to our helper functions. In this case, we’ll use the incoming dinosaur reference to specify the privilege.

const HasAccessToDinosaurType = (collectionName, accessType, dinoType) => {
  return HasAccessGeneric(
    And(
      Equals(dinoType, Select(['type'], Var('splitString'))),
      Equals(accessType, Select(['action'], Var('splitString'))),
      Equals(collectionName, Select(['collection'], Var('splitString')))
    )
  )
}
Enter fullscreen mode Exit fullscreen mode

If we would now specify the following permissions on our role:

Alt Text

We get the following results:

Alt Text

Implementing roles directly in Fauna

Finally, we will use Auth0 as the Identity provider that only provides the user identity. Then we’ll use Fauna to enforce the roles based on the user identity which means that although users will remain in Auth0, the relations between users and roles will be stored entirely in Fauna.

The disadvantage of this approach is that you can’t use Auth0’s UI to bind users to roles. The advantages are that changes to user and role relations will instantly have an effect which is not the case when roles or permissions are placed on the JWT token. In the previous approaches, as long as the token is valid, the user will keep the permissions/roles unless we revoke the token by revoking the public keys. The following is also a great approach in case you want to add more metadata to a user, metadata which might be application-driven and could be taken into account in the roles (e.g. think of StackOverflow where reputation grants more permissions).

Finally, you do not have to choose one approach, you can mix and match, you could have roles in Auth0 yet add extra permissions via Fauna based on the user or could use Auth0 rules to include IPs and geolocation to be used in Fauna security roles. The implementation of this approach can be found in this commit.

1. Creating the collections and indexes

To be able to support this, we have adapted the setup script to add a collection to map users to roles and a collection to hold the roles, as well as a few indexes to retrieve this information.

Alt Text

We also provided some default roles.

Alt Text

2. Using Auth0 rules to add a default role

There are many ways to start adding the data to these collections. You could use pure FQL to login and assign roles in one step or build an administrator UI that executes FQL queries to assign users to roles. To add a default role on signup, we can also use Auth0’s rules in which we can conveniently use the Fauna JavaScript driver.

First, we’ll need to make sure that the Auth0 rule can access the necessary Fauna resources for which we’ll make a new server key.

Alt Text

Grab the key on the next screen and drop it in Auth0 as a new variable for Rules.

Alt Text

Just like we have done before, we’ll then create a new rule in Auth0. Inside that rule, we can then write the following FQL query to assign a default role to the user. This rule will automatically run when a user logs in To make sure we only run it once, we use an if test to verify whether the user is already mapped to a role. Or, in other words, whether there is an entry in the ‘users_to_roles’ collection for that user for which we’ll use the 'users_to_roles_by_user' index.

function addRolesMapping(user, context, callback) {
  const Fauna = require('Fauna@2.11.1');
  const { query } = Fauna;
  const { Create, Collection, If, Exists, Get, Match, Index, Select } = query;

  const client = new Fauna.Client(
    { domain: 'db.fauna-preview.com', secret: configuration.Fauna_SERVER_KEY });

  client.query(
    If(
       Exists(Match(Index('users_to_roles_by_user'), user.user_id)),
      true, 
      Create(
        Collection('users_to_roles'), 
        {
           data: {
              user: user.user_id,
              role: Select(['ref'],Get(Match(Index('roles_by_name'), 'Public')))
           }
        }
      )
    )
  );

  callback(null, user, context);
}
Enter fullscreen mode Exit fullscreen mode

Once a user signs up, the following document will appear in Fauna.

Alt Text

We now have a default role for all users and can choose whether we further manage these roles from within our application or add more complex logic to Auth0 rules (e.g. based on location, IP, etc…) to determine what role the user should receive.

3. Creating an access role.

Next, we’ll write Fauna roles to enforce these based on the CurrentIdentity() which will return the sub of the token in case the token is a JWT token. We’ll create the Public role as an example, other roles would be similar.

CreateOrUpdateRole({
  name: 'loggedin_fauna_collections_public',
  privileges: [
    {
      resource: Collection('dinos'),
      actions: {
        read: Query(
          Lambda(
            ['dinoReference'],
            Let(
              {
                roleMapping: Get(Match(Index('users_to_roles_by_user'), CurrentIdentity())),
                role: Get(Select(['data', 'role'], Var('roleMapping'))),
                roleName: Select(['data', 'name'], Var('role'))
              },
              And(
                Equals(Var('roleName'), 'Public'),
                Equals(Select(['data', 'rarity'], Get(Var('dinoReference'))), 'common')
              )
            )
          )
        )
      }
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Given that this role only provides access to common dinosaurs,we only see two dinosaurs. In case we remove the mapping from the users_to_roles collection we will instantly lose access.

Alt Text

Until now, we have seen roles that do not deal with owning specific resources. However, we can easily apply the same techniques when ownership comes into play. We don’t have a way to set ownership defined in our sample application, but we could easily write an application where we assign newly created documents to a certain owner and write a role based on the ownership property.

Alt Text

Conclusion

By integrating Auth0 with Fauna, we receive SSO and identity management features with minimal effort. We are now left with multiple options to define our authorization rules and can choose the best solution depending on the application scenario. Since having many options might result in difficult choices, we have implemented these options and highlighted both advantages as disadvantages of each approach.

Although this guide is not exhaustive, these three examples should provide a good insight of what is possible when you combine Auth0 features likes rules and permissions with Fauna’s security system. A skeleton application was made available that contains the code for all of these implementations for you to experiment with further. Drop by our community forum in case these approaches spark further questions.

Discussion

pic
Editor guide