Abac graphql

ABAC + GraphQL

In this article, we will learn about FaunaDB's ABAC capabilities, and how they can be used with FaunaDB's native GraphQL API. To do so, we will go through an example use case showing how to build a comprehensive authorization implementation with FaunaDB's GraphQL API.

First, let’s start by revisiting the main aspects of the ABAC model.

What is ABAC?

ABAC stands for Attribute-Based Access Control. As its name indicates, it’s an authorization model that establishes access policies based on attributes. Attributes are characteristics that describe any of the different elements in the system. These can be one of the following types:

  • User attributes: describe the user attempting the access (e.g., role, department, clearance level, etc).
  • Action attributes: describe the action being attempted (e.g., read, write, delete, etc).
  • Resource attributes: describe the resource being accessed (e.g., owner, sensitivity, creation date, etc).
  • Environmental attributes: describe the environmental conditions on which the access attempt is being performed (e.g., location, time, day of the week, etc).

Through the combination of these attributes, ABAC allows the definition of fine-grained access control policies on a new level. This ability to express truly complex access rules is the key aspect of the ABAC model, and what makes it stand out from its predecessors.

FaunaDB implements ABAC as part of its integral security model, which allows the definition of attribute-based policies through its FQL API.

An example use case

Now that we've gone through the principles of the ABAC model, let's introduce an example use case that demonstrates FaunaDB's ABAC and GraphQL API features working together.

Since we will be building a GraphQL service, we are going to use the GraphQL Schema Definition Language (SDL) to model our domain. SDL is the most simple and intuitive, yet powerful and expressive, tool to describe a GraphQL schema. Its syntax is well-defined and is part of the official GraphQL specification.

So, for the domain model defined in the following GraphQL schema:

type User {
  username: String! @unique
  role: UserRole!
}

enum UserRole {
  MANAGER
  EMPLOYEE
}

type File {
  content: String!
  confidential: Boolean!
}

input CreateUserInput {
  username: String!
  password: String!
  role: UserRole!
}

input LoginUserInput {
  username: String!
  password: String!
}

type Query {
  allFiles: [File!]!
}

type Mutation {
  createUser(input: CreateUserInput): User! @resolver(name: "create_user")
  loginUser(input: LoginUserInput): String! @resolver(name: "login_user")
}

We are going to implement the following access rules:

  1. Allow employee users to read public files only.
  2. Allow manager users to read both public files and, only during weekdays, confidential files.

As you might have already noticed, these access rules include all of the different ABAC attribute types described earlier.

We will define the access rules through the FQL API, and then verify that they are working as expected by executing some queries from the GraphQL API.

With our goals already set, let’s put our hands to work!

Importing the schema

First, let’s import the example schema into a new database. Log into the FaunaDB Cloud Console with your credentials. If you don’t have an account yet, you can sign up for free in a few seconds.

Once logged in, click the NEW DATABASE button from the home page:

Choose a name for the new database, and click the SAVE button:

Next, we will import the GraphQL schema listed above into the database we just created. To do so, create a file named schema.gql containing the schema definition. Then, select the GRAPHQL tab from the left sidebar, click the IMPORT SCHEMA button, and select the newly-created file:

The import process creates all of the necessary database elements, including collections and indexes, for backing up all of the types defined in the schema. In the next step, we will implement the custom resolver functions for the createUser and loginUser Mutation fields.

Implementing custom resolvers

When looking at the schema, you might notice that the createUser and loginUser Mutation fields have been annotated with a special directive named @resolver. This is a directive provided by the FaunaDB GraphQL API, which allows us to define a custom behavior for a given Query or Mutation field.

On the database end, a template User-defined function (UDF) is created during the import process for each of the fields annotated with the @resolver directive. A UDF is a custom FaunaDB function, similar to a stored procedure, that enables users to define a tailor-made operation in FQL. This function is then used as the resolver of the annotated field.

Now, let’s continue to implement the UDF for the createUser field resolver. First, select the SHELL tab from the left sidebar:

As explained before, a template UDF has already been created during the import process. When called, this template UDF prints an error message stating that it needs to be updated with a proper implementation. In order to update it with the intended behavior, we are going to use FQL's Update function.

So, let’s copy the following FQL query into the command panel, and click the RUN QUERY button:

Update(Function("create_user"), {
  "body": Query(
    Lambda(["input"],
      Create(Collection("User"), {
        data: {
          username: Select("username", Var("input")),
          role: Select("role", Var("input")),
        },
        credentials: {
          password: Select("password", Var("input"))
        }
      })  
    )
  )
});

Your screen should look similar to:

The create_user UDF will be in charge of properly creating a User document along with a password value. The password is stored in the document within a special object named credentials that cannot be retrieved back by any FQL function. As a result, the password is securely saved in the database making it impossible to read from either the FQL or the GraphQL APIs. The password will be used later for authenticating a User through a dedicated FQL function named Login, as explained next.

Now, let’s add the proper implementation for the UDF backing up the loginUser field resolver through the following FQL query:

Update(Function("login_user"), {
  "body": Query(
    Lambda(["input"],
      Select(
        "secret",
        Login(
          Match(Index("unique_User_username"), Select("username", Var("input"))), 
          { password: Select("password", Var("input")) }
        )
      )
    )
  )
});

Copy the query listed above and paste it into the Shell’s command panel, and click the RUN QUERY button:

The login_user UDF will attempt to authenticate a User with the given username and password credentials. As mentioned before, it does so via the Login function. The Login function verifies that the given password matches the one stored along with the User document being authenticated. Note that the password stored in the database is not output at any point during the login process. Finally, in case the credentials are valid, the login_user UDF returns an authorization token called a secret which can be used in subsequent requests for validating the User’s identity.

With the resolvers in place, we will continue with creating some sample data. This will let us try out our use case and help us better understand how the access rules are defined later on.

Creating sample data

First, we are going to create a manager user. Select the GRAPHQL tab from the left sidebar, copy the following mutation into the GraphQL Playground, and click the Play button:

mutation CreateManagerUser {
  createUser(input: {
    username: "bill.lumbergh"
    password: "123456"
    role: MANAGER
  }) {
    username
    role
  }
}

Your screen should look like this:

Next, let’s create an employee user by running the following mutation through the GraphQL Playground editor:

mutation CreateEmployeeUser {
  createUser(input: {
    username: "peter.gibbons"
    password: "abcdef"
    role: EMPLOYEE
  }) {
    username
    role
  }
}

You should see the following response:

Now, let’s create a confidential file by running the following mutation:

mutation CreateConfidentialFile {
  createFile(data: {
    content: "This is a confidential file!"
    confidential: true
  }) {
    content
    confidential
  }
}

As a response, you should get the following:

And lastly, create a public file with the following mutation:

mutation CreatePublicFile {
  createFile(data: {
    content: "This is a public file!"
    confidential: false
  }) {
    content
    confidential
  }
}

If successful, it should prompt the following response:

Now that all the sample data is in place, let’s continue with defining the access rules that we described earlier. The access rules determine how the sample data we just created should be accessed.

Defining the access rules

In FaunaDB, access rules are defined in the form of roles. A role consists of the following data:

  • name — the name that identifies the role
  • privileges — specific actions that can be executed on specific resources 
  • membership — specific identities that should have the specified privileges

Roles are created through the CreateRole FQL function, as shown in the following example snippet:

CreateRole({
  name: "role_name",
  membership: [ 
    // ... 
  ],
  privileges: [ 
    // ... 
  ]
})

A privilege specifies how a resource in FaunaDB (e.g., a database, collection, document, index, etc.) can be accessed through a set of predefined actions (e.g. create, read, delete, history_read, etc.) and a predicate function. A predicate function is an FQL read-only function that returns true or false to indicate whether the access is permitted or not. The main purpose of a predicate function defined in the context of a role privilege is to read the attributes of the resource being accessed, and establish access policies based on them. 

Here’s an example snippet that shows how to define a privilege that establishes read access on public files for the File collection:

privileges: [
  {
    resource: Collection("File"),
    actions: {
      // Read and establish rule based on action attribute
      read: Query(
        // Read and establish rule based on resource attribute
        Lambda("fileRef",
          Not(Select(["data", "confidential"], Get(Var("fileRef"))))
        )
      )
    }
  }
]

In addition, attributes of the subject attempting the access, as well as environmental attributes, are available within the privileges’ predicate functions too. This enables establishing further policies based on them, if required. 

Membership defines a set of collections whose documents should have the role’s privileges. As in the case of privileges, a predicate function can be provided when defining the membership as well. The main purpose of a predicate function defined in the context of a role membership is to read the attributes of the user attempting access, and establish access policies based on them. 

Here’s an example snippet that shows how to define the membership for documents in the User collection that have a MANAGER role:

membership: {
  resource: Collection("User"),
  predicate: Query(
    // Read and establish rule based on user attribute
    Lambda("userRef", 
      Equals(Select(["data", "role"], Get(Var("userRef"))), "MANAGER")
    )
  )
}

In this case, the attributes of the resource being accessed are not available in the membership predicate function. Environmental attributes can be retrieved here though, in case they are required for defining further access rules based on them. 

In sum, FaunaDB roles are a very flexible mechanism that allows defining access rules based on all of the system elements attributes, with different levels of granularity. The place where the rules are defined — privileges or membership — determines their granularity and the attributes that are available, and will differ with each particular use case. 

Now that we have covered the basics of how roles work, let’s continue by creating the access rules for our example use case! In order to keep things neat and tidy, we’re going to create two roles, one for each of the access rules. This will allow us to extend the roles with further rules in an organized way if required later. Nonetheless, be aware that all of the rules could also have been defined together within just one role if needed. 

Let’s implement the first rule: 

“Allow employee users to read public files only.” 

In order to create a role meeting these conditions, we are going to use the following query:

CreateRole({
  name: "employee_role",
  membership: {
    resource: Collection("User"),
    predicate: Query( 
      Lambda("userRef",
        // User attribute based rule:
        // It grants access only if the User has EMPLOYEE role.
        // If so, further rules specified in the privileges
        // section are applied next.        
        Equals(Select(["data", "role"], Get(Var("userRef"))), "EMPLOYEE")
      )
    )
  },
  privileges: [
    {
      // Note: 'allFiles' Index is used to retrieve the 
      // documents from the File collection. Therefore, 
      // read access to the Index is required here as well.
      resource: Index("allFiles"),
      actions: { read: true } 
    },
    {
      resource: Collection("File"),
      actions: {
        // Action attribute based rule:
        // It grants read access to the File collection.
        read: Query(
          Lambda("fileRef",
            Let(
              {
                file: Get(Var("fileRef")),
              },
              // Resource attribute based rule:
              // It grants access to public files only.
              Not(Select(["data", "confidential"], Var("file")))
            )
          )
        )
      }
    }
  ]
})

Select the SHELL tab from the left sidebar, copy the above query into the command panel, and click the RUN QUERY button:


Next, let’s implement the second access rule:

“Allow manager users to read both public files and, only during weekdays, confidential files.”

In this case, we are going to use the following query:

CreateRole({
  name: "manager_role",
  membership: {
    resource: Collection("User"),
    predicate: Query(
      Lambda("userRef", 
        // User attribute based rule:
        // It grants access only if the User has MANAGER role.
        // If so, further rules specified in the privileges
        // section are applied next.
        Equals(Select(["data", "role"], Get(Var("userRef"))), "MANAGER")
      )
    )
  },
  privileges: [
    {
      // Note: 'allFiles' Index is used to retrieve the 
      // documents from the File collection. Therefore, 
      // read access to the Index is required here as well.
      resource: Index("allFiles"),
      actions: { read: true } 
    },
    {
      resource: Collection("File"),
      actions: {
        // Action attribute based rule:
        // It grants read access to the File collection.
        read: Query(
          Lambda("fileRef",
            Let(
              {
                file: Get(Var("fileRef")),
                dayOfWeek: DayOfWeek(Now())
              },
              Or(
                // Resource attribute based rule:
                // It grants access to public files.
                Not(Select(["data", "confidential"], Var("file"))),
                // Resource and environmental attribute based rule:
                // It grants access to confidential files only on weekdays.
                And(
                  Select(["data", "confidential"], Var("file")),
                  And(GTE(Var("dayOfWeek"), 1), LTE(Var("dayOfWeek"), 5))  
                )
              )
            )
          )
        )
      }
    }
  ]
})

Copy the query into the command panel, and click the RUN QUERY button:


At this point, we have created all of the necessary elements for implementing and trying out our example use case! Let’s continue with verifying that the access rules we just created are working as expected...

Putting everything in action

Let’s start by checking the first rule:

“Allow employee users to read public files only.”

The first thing we need to do is log in as an employee user so that we can verify which files can be read on its behalf. In order to do so, execute the following mutation from the GraphQL Playground console:

mutation LoginEmployeeUser {
  loginUser(input: {
    username: "peter.gibbons"
    password: "abcdef"
  })
}

As a response, you should get a secret access token. The secret represents that the user has been authenticated successfully:

At this point, it’s important to remember that the access rules we defined earlier are not directly associated with the secret that is generated as a result of the login process. Unlike other authorization models, the secret token itself does not contain any authorization information on its own, but it’s just an authentication representation of a given document.

As explained before, access rules are stored in roles, and roles are associated with documents through their membership configuration. After authentication, the secret token can be used in subsequent requests to prove the caller’s identity and determine which roles are associated with it. This means that access rules are effectively verified in every subsequent request and not only during authentication. This model enables us to modify access rules dynamically without requiring users to authenticate again.

Now, we will use the secret issued in the previous step to validate the identity of the caller in our next query. In order to do so, we need to include the secret as a Bearer Token as part of the request. To achieve this, we have to modify the Authorization header value set by the GraphQL Playground. Since we don’t want to miss the admin secret that is being used as default, we’re going to do this in a new tab.

Click the plus (+) button to create a new tab, and select the HTTP HEADERS panel on the bottom left corner of the GraphQL Playground editor. Then, modify the value of the Authorization header to include the secret obtained earlier, as shown in the following example. Make sure to change the scheme value from Basic to Bearer as well:

{
  "authorization": "Bearer fnEDdByZ5JACFANyg5uLcAISAtUY6TKlIIb2JnZhkjU-SWEaino"
}

With the secret properly set in the request, let’s try to read all of the files on behalf of the employee user. Run the following query from the GraphQL Playground:

query ReadFiles {
  allFiles {
    data {
      content
      confidential
    }
  }
}

In the response, you should see the public file only:

Since the role we defined for employee users does not allow them to read confidential files, they have been correctly filtered out from the response!

Let’s move on now to verifying our second rule:

“Allow manager users to read both public files and, only during weekdays, confidential files.”

This time, we are going to log in as the employee user. Since the login mutation requires an admin secret token, we have to go back first to the original tab containing the default authorization configuration. Once there, run the following query:

mutation LoginManagerUser {
  loginUser(input: {
    username: "bill.lumbergh"
    password: "123456"
  })
}

You should get a new secret as a response:

Copy the secret, create a new tab, and modify the Authorization header to include the secret as a Bearer Token as we did before. Then, run the following query in order to read all of the files on behalf of the manager user:

query ReadFiles {
  allFiles {
    data {
      content
      confidential
    }
  }
}

As long as you’re running this query on a weekday (if not, feel free to update this rule to include weekends), you should be getting both the public and the confidential file in the response:

And, finally, we have verified that all of the access rules are working successfully from the GraphQL API!

Conclusion

In this post, we have learned how a comprehensive authorization model can be implemented on top of the FaunaDB GraphQL API using FaunaDB's built-in ABAC features. We have also reviewed ABAC's distinctive capabilities, which allow defining complex access rules based on the attributes of each system component.

While access rules can only be defined through the FQL API at the moment, they are effectively verified for every request executed against the FaunaDB GraphQL API. Providing support for specifying access rules as part of the GraphQL schema definition is already planned for the future.

In short, FaunaDB provides a powerful mechanism for defining complex access rules on top of the GraphQL API covering most common use cases without the need of third-party services.