Dirk Johnson
Posted on July 21, 2020
Introduction
[Note: this tutorial was written by commission for Fauna, Inc]
If you are reading this tutorial, something about it caught your eye. Perhaps it was the reference to Elm, a delightful language for reliable web apps. Perhaps it was GraphQL, which is an abstract query language and service for your backend APIs. Or perhaps it was FaunaDB, a full-featured, cloud-based datastore built for serverless computing. Hopefully it was all three!
This tutorial will cover how to bring all three of these capable technologies together as a cohesive solution for writing JAMstack applications.
Oops, I just dropped another term on you: “JAMstack”. Let's provide further explanation for all these terms, starting with JAMstack.
JAMstack
JAMstack is an approach to architecting your web applications. It is not opinionated about what technologies you use to do this, other than requiring Javascript (J), APIs (A), and Markup (M).
The core JAMstack architecture builds upon static content enhanced by client-side Javascript and serviced by microservice APIs. Notice that there is no mention of a tightly-coupled web application behind your site! This means your entire application (with the exception of your microservice functions,) can be cached and delivered by a Content Delivery Network (CDN). This simplified application architecture leads to better performance, higher security, and straightforward scaling.
Serverless Computing
While browsers are getting more and more capable, to provide security and persistent, global data storage for your client, you still need to back your JAMstack applications with server-side services. However, rather than backing your client with a monolithic backend application server requiring development, deployment, and support cycles, in the JAMstack architecture, we use "serverless" microservices which are accessed directly or through stateless functions. These functions and microservices run in the cloud, and require little to no support on your part.
I realize all of this sounds wonderful at a high level, and that discerning developers are going to need to go deeper to be confident of the advantages of the JAMstack architecture, but for the moment, accept the coolness, and join me in discovering how to leverage some technologies custom fitted for JAMstack; please welcome FaunaDB, GraphQL, and Elm.
FaunaDB
Behind every data query is a data store. Choosing the right data store for your application architecture is, in my opinion, one of the most important architectural decisions you will make for an enterprise-level application. There are many considerations to choosing the right data-store, such as the ability to impose data integrity assurances and constraints, data security, reliable backup-and-restore, ease of scaling, performance, and the list goes on.
And in today's JAMstack architectures, there are new considerations like schema flexibility, minimal operational investment, data consistency, global low latency, and, of course, ease of backing a microservice architecture. Whew!
FaunaDB is a database built from the ground-up to address all of these checkbox items and more. One that I am particularly excited about is FaunaDB’s native GraphQL service which makes implementing a GraphQL interface as simple as defining your schema, importing it, and fleshing out the resolvers. Zap!
GraphQL
Applications and services need to communicate with each other. As with all effective communication, each side of the conversation needs to listen to and speak the same language. GraphQL is both a language and a service that facilitates querying for data, mutating data, and even subscribing to messages about data. Those familiar with JSON, an extremely popular exchange format for structured data, should feel right at home with GraphQL, as its type structures are expressed in a similar format.
One of GraphQL's benefits is the ability to not only provide a schema for the shape of your data, but also the types of your data (which is very nice for Elm). It also allows you to dynamically limit the data members returned from your structured data, thus reducing the total size of the data passed over the wire, especially when compared to other data-exchange interfaces such as REST.
Elm
Elm is a strongly typed, functional language that transpiles to Javascript. Because of its type guarantees and pure, functional approach, it boasts an amazing compiler with very friendly error reporting, and a near perfect absence of runtime errors (by real-world measurements).
Elm is also an architecture for web applications (lovingly referred to as The Elm Architecture, or TEA). The key concepts of this architecture are a centralized Model for your data, a View (HTML) purely driven by updates to the Model, and an update
function that responds to specific messages from the DOM (or a worker, or an XHR request, etc.), which then updates the Model, which then triggers an update to the View, and so on. This clean, reactive design pattern is found in some form in other frameworks such as Redux and Vuex; however, coupling it with a pure, functional language makes this pattern much more natural so architecting your own application is intuitive, allowing you to focus on what makes your project special.
A Triumvirate for JAMstack
Each of these technologies, FaunaDB, GraphQL, and Elm, are great technologies on their own, but bringing them together creates an even more powerful combination. FaunaDB boasts powerful features with an amazing ease of setup and management in the serverless space. On the client side, you have Elm, with its strong runtime guarantees built upon a simple, reactive architecture. And, bringing these two together in a well designed, typed, data exchange is GraphQL. Together, each layer of the JAMstack is both fun and profitable. And isn’t that why we do this job – so we can have fun?
So… let’s go play!
Shall We Play?
In this 2 part article, you are going to learn how to bring these technologies together in a unified, secure, JAMstack architecture. In part 1, we will focus on FaunaDB and it’s native GraphQL support by building out our schema and identity management architecture. In part 2, we will focus on Elm, and, more specifically, on a package called elm-graphql, that will allow us to easily interface an Elm application with the backend services we implemented using FaunaDB.
However, before we even get started, we will first need an Elm JAMstack application ready and waiting to be integrated with a backend service. As luck would have it, there is one waiting and ready for us called "Shall We Play?". Shall We Play? is a not-so-full-featured site for scheduling game nights with friends.
For this introduction to Shall We Play?, you can visit the basic, deployed version of the application.
Logging Into Shall We Play?
Since Shall We Play? does not currently have a secure login, all that is necessary to sign in right now is a user name. If that user name exists, you will be logged in as that user; if it does not, it will be created.
Go ahead and enter any user name you'd like and hit return or click on the login button.
The likelihood is that you chose a user name that did not exist. If that is the case, then you should see a view similar to the following:
As should be expected when logging in with a new account, Shall We Play? tells you that you have organized 0 events and that you are invited to 0 events.
If you did choose a user name that existed (against all odds), just log out and log back in with a new user name.
Below the logout panel you will see a list of "All Other Players". This is a list of all registered players (except for yourself, of course).
The "Events" section below the players list is also empty. This should always correspond to the event summary reported at the top.
Creating Your First Event
So, you want to play Star Fleet Battles with your friends Solo and Charlie – how do you start a new event? To the right, under the summary header is text that says "Drag a player to start an event". Cool, let's do that. Go ahead and start dragging "Charlie".
As you drag "Charlie", you will see a dialog pop up. Drop "Charlie" on this dialog. Go ahead and drag "Solo" over as well. Add a creative title, description, and venue. Set a date for the event, then click "Save".
You should now see your first event under "Events". The number of events you have organized should also be reported as 1. Notice how "Charlie" and "Solo" have yellow backgrounds; this is because they have not yet accepted their invitation from you.
Log out of your account by clicking on the logout button and log back in as Charlie and accept the invitation by clicking on "Update to I Am Going".
You should now see Charlie's "Going?" status set to "Yes" with a green background.
Log out of Charlie's account and log back in as yourself. You should now see "Charlie" with a green background.
Peeking Under The Hood
There are a few more features to discover about Shall We Play?, but not much more. And for the curious, feel free to peek under the hood at the Elm code. We will go into far more detail later on regarding how Shall We Play? is architected, but at this point, it could be fun to take an unguided tour.
As you explore the code for Shall We Play? you will notice that there is no pre-render step for this simple application, which is sometimes considered a hallmark feature of a JAMstack site. Please note that there are two solid static site generators for Elm that I am aware of (and have used). The first is inspired by Jekyll, called Elmstatic by Alex Korban (who has also authored a great Elm book). The second is inspired by Gatsby, called Elm Pages by Dillon Kearns. In either case, I did not want to add to the complexity of a very simple application, so I chose to keep Shall We Play? minimal, while still keeping to the tenets of JAMstack, that is, deploying completely from a CDN and being backed by a multi-region, zero-operation database.
Securing Our Login with FaunaDB
Shall We Play? is nice, but it has a few issues. First, it is not very useful. Other people logging into a different application instance will not see any of the events that you invited them to. And if you reload the page, all of your scheduled events go away. Oops! By moving to FaunaDB as your backend data store, you will be able to persist your data so that everyone using the site will see the same data and feel comfortable that their data is going to be there when they visit again.
The second issue is that Shall We Play? is not secure; if anyone knows your user name, they can log in as you. And, if they log in with any name, they can see yours and everyone else's user names, so there is not even the comfort of "security through obscurity" (which, as the saying goes, "is no security at all").
The most important issue to solve is the insecure login and so, will be the primary issue we address in this tutorial. Once we have added secure login, you will have all the tools in your tool belt that you need to add saving events to the database.
The Registration And Login Use Case
FaunaDB's native integration with GraphQL as well as its identity management and attribute-based access control (ABAC) will allow us to provide an elegant solution to securely logging into our application. But first, we are going to put on our thinking caps and think through what we need to do to allow a user to register and login, and then access player user name data. If we don't get this right, we risk exposing data to unauthorized clients and peers. So let's get it right.
First, we need to acknowledge that our client is untrusted. Anything our client has or says cannot be trusted. Yes, it's true. The reason is, and it's a simple reason, that in the web application world, everything our client application knows and sees is accessible by the user, including uninvited users. This goes for images, JavaScript code (as obfuscated as it may be), data in memory; everything is available to the uninvited user.
Now let's think about the data store. We could approach data security by being miserly and not let anyone have it. But then that wouldn't be a very useful data store. Data is meant to be consumed, responsibly, of course. So what we need to do is define which data and data operations are safe to share with potentially uninvited users, and which data and data operations can be shared with authenticated and authorized users, which we will call "players".
Finally, let's create a use case for how Shall We Play? will behave when registering and logging in a new player. We'll start by reviewing how Shall We Play? currently does it:
A new player goes to the Shall We Play? website and sees the login prompt. The new player enters a new user name and clicks on the login button. A new account is created and the player is logged in, where they can see the user names of all other registered players.
This is a nice, simple approach, but to secure the login process, we need to require a password. Let’s update this use case by adding a password to the login flow:
A new player goes to the Shall We Play? website and sees the login prompt. (At this point, they are, by default, untrusted and therefore considered uninvited.) The new player enters a new user name and clicks on the login button. The password field is then presented and the new player enters a password and clicks on the login button again. A new account is created and the player is logged in where they can see the user names of all other registered players. (At this point, the new player is a trusted player.)
This new use case tells us what we need to know to build our permissions structure for logging in and sharing player data:
- We need to allow untrusted players to create and/or login to an account
- We need to hide player data from untrusted players, but make user names accessible to trusted players once they log in
Perfect!
Creating Our Initial GraphQL Schema
Now that we know what data, operations, and permissions we need to support for registration and login, we can start to define our GraphQL schema. Go ahead and create a schema file with a proper name, ending with the suffix .gql
.
While this tutorial does not require you to be an expert in GraphQL, having a cursory understanding of GraphQL schemas, queries, and mutations will help tremendously.
If you took time earlier to look at the underlying Elm code, you would have noticed that the Player type has two fields: user_name
of type UserName
(which, under the hood, is a String
) and member_since
of type Int
.
So on the GraphQL side we will follow suit and, in our new schema file, define a Player type with a user_name
field of type String!
and a member_since
field of type Int!
:
type Player {
user_name : String!
member_since : Int!
}
The !
means the field value is non-nullable (i.e., required to have a non-null value).
The next thing we need is a GraphQL mutation we can use to register a new player and/or log a player into the application. This mutation will need to support the new player use case we outlined above as well as support a returning player logging in (or rejecting them if the password is wrong). This means we will only need to define one mutation for both registration and login:
type Mutation {
createAndOrLoginPlayer(user_name: String!, password: String!): String!
}
Both the user_name
and password
parameters to the createAndOrLoginPlayer
call are required and the return type is a String that cannot be null. This string will be a very important token which represents both the identity of the now logged in player as well as what data they have permission to access.
Finally, to meet our requirements outlined above, we will define a GraphQL query that can only be called by a logged in (trusted) player, and that can only return player user names and nothing else about a player:
type Query {
allUserNames: [String!]!
}
If you are confused by this notation with the !
both outside and inside the array, you would read this as "the array is non-nullable, but can be empty, and if the array does have values, they must be Strings, no nulls allowed."
It is idiomatic to provide a query of the form
allType: [Type!]
that returns all records of a certain type, for each of the types in the schema. However, anallPlayers
query would not be useful to us for this tutorial, because we do not want a query that allows the return of any/all properties of a player. Rather, we just need to get back a list of user names.
Adding FaunaDB GraphQL Directives To Our Schema
Our GraphQL schema is now complete in that it represents all we need in order to register and log in a new player, however, due to FaunaDB's tight integration with GraphQL, there is more that we can add to the schema that will help. FaunaDB’s GraphQL Directives automate the creation of database resources such as collections, indexes, and functions. These directives save us a lot of time by creating what we would otherwise have to create manually. All GraphQL Directives start with an @
. FaunaDB provides several custom directives but we will only concern ourselves with two: @unique
and @resolver
.
One of the things we will need to ensure is that no two players have the same user name. FaunaDB defines unique constraints like this through Indexes. In order to tell FaunaDB to create an index to ensure that the user_name
field remains unique, we will add the @unique
directive to the user_name
field:
type Player {
user_name: String! @unique
member_since: Int!
}
Next, we need to make a tweak to the mutation and query we have defined in our schema. FaunaDB will evaluate our schema when it is imported and create some default queries and mutations, but our specific query and mutation will require us to define a User Defined Function (UDF) for each. These UDFs will be used to perform the business logic surrounding registration and login as well as specifically returning just the player data that the logged in player is authorized to see.
At this point you might ask why FaunaDB doesn’t just provide the registration and login logic for us as this is such a common feature? Good question! The reason is that the business policies surrounding registration and login can be complicated and are highly variable between different use cases. FaunaDB, rather, provides you with the building blocks necessary to create whatever policies you need. More on that later.
When UDFs are attached to a query or mutation, they are called resolvers:
type Mutation {
createAndOrLoginPlayer(user_name: String!, password: String!): String! @resolver(name: "create_andor_login_player")
}
type Query {
allUserNames: [String!]! @resolver(name: "all_user_names")
}
Notice how the @resolver
takes a name
parameter; this is the name of the UDF that FaunaDB will create for us when this schema is imported. The UDFs that are auto-created will, by default, throw an error when called. This is to remind us that we actually have to update these functions with our own business logic, which we will do shortly.
We are now ready to import our new schema into our brand new database... wait... what database?
Signing Up For Your FaunaDB Account
In order to proceed, we are going to need a database for Shall We Play?. Fauna has a free tier that allows you to take full advantage of FaunaDB’s features, with a generous daily usage quota. If you would like to follow along, and you haven’t already signed up for your free account, you can go directly to the registration page, log in with your GitHub or Netlify account, or use your email to create a new Fauna account.
Creating The FaunaDB Database
Now that you have a Fauna account, let’s create the database for Shall We Play?:
- Log into your FaunaDB account; you should be brought to your FaunaDB Console Home
- Create a new database by clicking “New Database” in the upper-left
- Let’s name the database "swp", nice and simple. Save the new database.
You will be brought to the DB Overview for your new database.
Notice the tabs to the left for collections, indexes, and functions. We will visit these in a bit. Also notice there are tabs for an interactive FQL shell, GraphQL management, and security management. We will be visiting all of these tabs before we are done.
Documents, Collections, Indexes, and Functions
As noted before, the core of any communication is speaking the same language. In the tech world, that means understanding acronyms and terms in their respective contexts. In that light, let’s review a few terms in the context of FaunaDB: Documents, Collections, Indexes, and Functions.
First, if your background is SQL, there is a nice section in the documentation drawing comparisons between FaunaDB and SQL. If this is for you, take a look.
A Document is an editable record that stores related data surrounding a single concept, like a Player or a Book or an Author. It references these data by keys or field names, similar to how a JSON Object works. It is similar in concept to a row in a SQL table. It will always have an identifier or reference.
A Collection is a grouping of documents. It serves to categorize the documents it holds, though there is no constraint on what the structure of its constituent documents must look like. Indeed, the documents within a single collection need not have any fields in common (though ensuring they do, at least in part, makes having different collections much more useful).
An Index facilitates flexible data searches and sorts on a single collection. Documents in a collection are always accessible by the document’s reference; however, retrieving documents by their data fields requires an index. Indexes also serve to enforce constraints, like unique values within a common field or fields, and they are inherently sorted.
FaunaDB Functions are bits of code written in FQL (the Fauna Query Language). Functions can be used to enforce secure and authorized access to data, to automate maintenance, and to enforce business logic. When functions are used as resolvers for GraphQL queries and mutations, they are called User Defined Functions (UDFs).
For further review of these terms and many others, please see the FaunaDB Glossary.
Importing our GraphQL Schema
Now that we have learned these new terms, let’s do something with them; let’s import our GraphQL schema into our freshly created database. As a review, our GraphQL schema looks like the following:
type Player {
user_name: String! @unique
member_since: Int!
}
type Mutation {
createAndOrLoginPlayer(user_name: String!, password: String!): String! @resolver(name: "create_andor_login_player")
}
type Query {
allUserNames: [String!]! @resolver(name: "all_user_names")
}
Make sure the schema you have ready to import looks like this (and only this) and that its file name ends in .gql
. Now lets import:
- Go to the Console Home of your "swp" database and click on the “GRAPHQL” tab
- Import your GraphQL schema by clicking on “IMPORT SCHEMA” and selecting your schema file from the file selection dialog
If your schema imports correctly, you should now see a GraphQL Playground which will allow you to test your GraphQL service.
If you received an error importing your schema, review your schema’s contents, make sure it is identical to the one listed above, and try again.
With the GraphQL Playground up, click on the “SCHEMA” tab in grey to the right. This is what your schema looks like after FaunaDB imported your schema and messaged it for you.
Notice you still see your Player type with user_name
and member_since
fields, but it also has a few more fields called _id
and _ts
, which are leveraged by FaunaDB internally to track your record over time.
Also notice that you actually have four mutations you can call, one of which is your custom mutation called createAndOrLoginPlayer
. And your allUserName
query is there along with another automatically provided for you by FaunaDB, because, in this case, Fauna knows best.
Now visit the “COLLECTIONS” tab on the left. Notice that based on the schema you imported, FaunaDB automatically created a Player collection for you.
Now visit the “INDEXES” tab. Again, based on the schema you imported, and more specifically, based on the @unique directive you placed on the user_name
field of Player, FaunaDB automatically created an index for you that will let you search by user_name
and also ensure that any inserted user names will be unique.
Finally, visit the “FUNCTIONS” tab. Here you will see two functions created for our two resolvers. The names should correspond to the ones you specified in your schema.
Take a look at their FQL code – though you may not understand FQL per se, by just reading them you should get a good idea as to what they do – they abort execution with a message that we need to actually add real behavior to these UDFs.
Don’t worry, that part is coming up real soon.
But first, we need to discuss exactly how we are going to make sure our data is safe from uninvited users.
Creating The Permissions Structure In FaunaDB
We will be leveraging FaunaDB’s flexible attribute-based access control (ABAC) to secure our application. This access control model is quite deep and nuanced, but also extremely powerful and flexible. I will not attempt to provide you a complete explanation of all its features and interactions, but will rather use specific examples relevant to our project to explain how the ABAC model will work for us.
First Question: What Do I Know?
The doorway into FaunaDB’s attribute-based access control system is always through a “secret”. If FaunaDB can’t verify what you know, then it won’t let you in. There are 2 types of secrets: Tokens and Keys. Tokens are identities that are tied to a specific document in a specific collection. They are usually tied to a collection that conceptualizes a real individual like “user” or “customer”. Tokens are generated by “logging in” with the proper credentials (password).
Keys, on the other hand, are global secrets independent of any collection in your database. They are not generated through logging in, but are created by the database administrator, and their distribution should be carefully controlled. Both types of secrets are important and have different use cases as you will see next.
The astute reader will have already realized where in our use case we will need a key and where we will need a token. Lets review the requirements of our use case:
- We need to allow untrusted players to create and/or login to an account
- We need to hide player data from untrusted players, but make user names accessible to trusted players once they log in
So, for the first requirement, we will need a custom key for untrusted players. We will need to make sure this key can only create an account and/or log in to the application. And for the second requirement, we will need a token, gained when logging in, to provide access to player user names.
Second Question: What Can I Do?
All Keys are tied to Roles, and Roles dictate what actions we can take on which resources. Looking at the first requirement of our use case, we need our key to be able to call the create_andor_login_player
function, so we will need to tie our key to a role that can do this. Let’s call that role bootstrap
:
Third Question: What Can My Functions Do?
Remember we said that roles dictate what actions we can take on which resources? Our functions themselves need access to resources as well. So, in this case, we need a role for our create_andor_login_player
function. To determine which actions our function will need to take on which resources, lets diagram the flow of logic for this function and see what resources it uses:
- The client calls
create_andor_login_player
with a user name and password - The function searches for a Player record with the user name provided using an index
- If there is no Player record with that user name, then we create a Player with that user name and password
- We attempt to login with the player (either a newly created Player or an existing one) using the password
- If the login is successful, we return the login token to the client
In summary, it looks like we will need to (1) read (use) an index (called unique_Player_user_name
) to search for a player by user_name
. It also looks like we will need to (2) read the Player the index may find, and finally, we will need to (3) create a new Player if the index finds none. So lets create a role called register
that has permission to do these three things:
Setting Up Your Permissions With FQL
We’ve outlined secure permissions for logging in. I know we still need to set up the permissions for calling our all_user_names
UDF, but I’m anxious to get what we have just designed set up and tested in our GraphQL Playground. So let’s do that, shall we?
But first, a little more about FQL. FQL is a concise, functional, query language native to FaunaDB. Anything you can do with the database can be done in FQL. I’m not going to be teaching you FQL in this tutorial, but we will be using it. The language is descriptive enough that you should get the idea of what we are doing from reading the code and my explanations. However, if you’d like to learn more about FQL before (or after) proceeding, I recommend you start with their tutorials.
We will be entering our FQL in the web-based shell for our "swp" database, so let’s get into the shell first:
- Log into your FaunaDB account; you should be brought to your FaunaDB Console Home
- Go to the DB Overview for your "swp" database by clicking on the name of the database
- Go to the shell console by clicking on the SHELL tab to the left
Dominating most of the page is the log and the editor areas; the log is on top and the code goes in the editor at the bottom. If this is the first time you have used the shell, you will see 3 lines of code entered for you:
Paginate(Collections());
Paginate(Indexes());
Paginate(Databases());
Go ahead and run this code by clicking on “RUN QUERY” (you can also run the code when focused in the editor using Cmd+return). You should see output in the log similar to the following:
Paginate(Collections());
Paginate(Indexes());
Paginate(Databases());
[
{
"data": [
Collection("Player")
]
},
{
"data": [
Index("unique_Player_user_name")
]
},
{
"data": []
}
]
>> Time elapsed: 109ms
The return data is contained within an array which begins and ends with square brackets. As there were three calls, there were three JSON-like objects returned inside this array, each having a field named “data”. The first call asked for a list of all collections; notice that the first data point has a list (array) with your Player collection. The second call asked for a list of all indexes; notice that the second data point has a list with your unique_Player_user_name
index. Finally, the third call asked for a list of the child databases in our "swp" database, of which we have none. (We will not be covering child databases in this tutorial.)
As I introduce new code, I will first show you the code, then provide an explanation, and then instruct you to enter the code into the editor and run the query. Be sure to read the explanation first before executing the code. Understanding what you are doing before actually doing it is an important pattern to remembering what you learn.
Let’s start fleshing out our permissions by first creating our bootstrap
role. Let’s take a look at the FQL code:
CreateRole({
name: "bootstrap",
privileges: [
{
resource: Function('create_andor_login_player'),
actions: {
call: true
}
}
]
});
The description of the role is contained in an object. It has name
and privileges
properties. The name we have chosen is “bootstrap”, and the privileges indicate that this role has permission to call the create_andor_login_player
function.
Go ahead and enter the code and execute the query; be sure to replace any code that might be in the editor already.
The core of the output should look something like this:
{
"ref": Role("bootstrap"),
"ts": 1590968733065000,
"name": "bootstrap",
"privileges": [
{
"resource": Ref(Ref("functions"), "create_andor_login_player"),
"actions": {
"call": true
}
}
]
}
This is what the role record looks like. It has a unique reference (“ref”), a timestamp (“ts”), a name, and our privileges. We won’t always review the log output of our FQL commands, but always look them over and make sure you understand the gist of it.
Tip: You can clear out the log by clicking on the “CLEAR” button at the top of the page.
Now let’s create our custom key that is tied to this new role:
CreateKey({
name: "Bootstrap Key",
role: Role("bootstrap")
})
Creating keys is pretty straight forward: choose a role and give it a name.
Go ahead and enter the code and execute the query.
The core of the output should look something like this:
{
"ref": Key("267083179018420748"),
"ts": 1590969218195000,
"name": "Bootstrap Key",
"role": Role("bootstrap"),
"secret": "zzA20468euAbCdEMOlvgSa8xQvJLRBQH7BfXh4iu",
"hashed_secret": "$2a$05$/OmiXQiSTDpuqr1KLpv/S.5VOfu6tYwB6FdPGnCrtZEWUnz8QcGhW"
}
The output from this command is very important to save, at least one part of it – the secret
. The secret is the actual key that our Elm client will use to create and log in players. This is the only time the key will ever be displayed by FaunaDB. If you lose this secret, you will have to create a new one – there is no way to recover it.
Save this secret somewhere safe; we will need it later.
The secret listed above in this tutorial is not a real key – it has been changed to protect the innocent.
We have now set up the first half of the permissions structure we outlined above. Let’s continue with the second half by creating the role our UDF will use to access our index and read and create players:
CreateRole({
name: "register",
privileges: [
{
resource: Collection("Player"),
actions: {
create: true,
read: true
}
},
{
resource: Index("unique_Player_user_name"),
actions: {
read: true
}
}
]
})
This register
role is a little more complex than the last role we created. Instead of specifying privileges for a function, we are specifying privileges for our Player collection (create and read permissions), and for our unique_Player_user_name
index (read permissions).
Go ahead and enter the code and execute the query.
Now we need to update our placeholder UDF created for us by the import of our GraphQL schema:
Update(Function("create_andor_login_player"), {
name: "create_andor_login_player",
body: Query(Lambda(["user_name", "password"],
Let({
match: Match(Index("unique_Player_user_name"), Var("user_name")),
player: If(
Exists(Var("match")),
Get(Var("match")),
Create(Collection("Player"), {
credentials: { password: Var("password") },
data: {
user_name: Var("user_name"),
member_since: ToMillis(Now())
}
})
),
login: Login(Select("ref", Var("player")), { password: Var("password") })
},
Select("secret", Var("login"))
)
)
),
role: Role("register")
})
Woah! There is a lot going on here. Don’t panic. Let’s break it down.
First you will notice that we are actually updating our function as opposed to creating it (as we created the new roles and key). That is because this function already exists, so we need to modify it.
The core of the function is in the body
field. When we create a function, the function wraps what is called a Lambda function. A Lambda function is basically an unnamed function. In this case, FQL uses Lambda functions to specify function behavior. When we designed our permissions structure, we actually went over the flow of this function – let’s review what we wrote in the context of what we see above:
- The client calls
create_andor_login_player
with a user name and password; those parameters are passed into theLambda
function. - The function searches for a Player record with the user name provided using an index. This is done with the
Match
built-in function, which is using theunique_Player_user_name
index. - If there is no Player record with that user name, then we create a Player with that user name and password. The
Exists
built-in function tests whether theMatch
call returns anything. If it doesn’t, it creates the player with the built-inCreate
call. Notice how in theCreate
call, the password is passed in as credentials for this specific player. - We attempt to login with the player (either a newly created Player or an existing one) using the password. This is done with the
Login
built-in function. - If the login is successful, we return the login token to the client. The
Select
built-in function returns the secret, which is the token from the result of theLogin
call.
Finally, notice that the role for this function is set to register
.
Go ahead and enter the code and execute the query.
Looking at our two diagrams above, I think we have covered everything. Now we are ready to test our createAndOrLoginPlayer
GraphQL mutation.
Testing Our Permissions with GraphQL Playground
We are now going to test our createAndOrLoginPlayer
GraphQL mutation. We will do this from the GraphQL Playground found under the “GRAPHQL” tab; go there now.
The GraphQL Playground is a neat tool that lets you query your GraphQL service, and push against your GraphQL schema. On the left, you have a GraphQL query editor, on the right you have the output. And further to the right you have the “DOCS” and “SCHEMA” tabs. When you first imported your schema, we took a look at the “SCHEMA” tab. Go ahead and look at the “DOCS” tab now.
The “DOCS” tab shows you a different view of your schema than the “SCHEMA” tab. It presents your queries and mutations in the form of documentation that is useful for assisting you building complex queries. With more complex schemas than ours, this is a life-saver. Notice there is a search bar at the top so you can even search for that one query you can’t seem to get right. Go ahead and close the “DOCS” tab.
We are now going to create a mutation operation to test our schema and permissions. This operation will come from the GraphQL Playground “client”. As mentioned before, our Elm client will have to make this mutation call using our custom key. So, to test our permissions, we will need the GraphQL Playground client to use this key as well. At the bottom of the query editor is a footer called “QUERY VARIABLES”. Expose the “QUERY VARIABLES” section by clicking on the footer.
You will see an HTTP “authorization” header with a default key using basic encoding. We will replace this key with our custom key in “Bearer” format (non-encoded format). Insert your custom key into this template and replace the headers object with this new one:
{
"authorization": "Bearer your-custom-key-here"
}
Now enter the following mutation in the query editor, but don’t execute it just yet:
mutation CreateAndOrLoginPlayer {
createAndOrLoginPlayer(user_name:"example_user_name", password:"example_password")
}
This mutation operation calls the createAndOrLoginPlayer
query with a user name of “example_user_name” and a password of “example_password”. When this query is executed, it will trigger the resolver that backs this query, passing in the user name and password. If all goes well, the UDF will create a new record in the Player collection with this user name and return a player token.
In the center between the query editor and the log is a “play” button. Go ahead and execute this mutation by clicking on the “play” button.
Assuming all went well, your output should look something like this:
{
"data": {
"createAndOrLoginPlayer": "fnEDtOiSt-CCAb00k_rewAIHaEzzJe5lKGBcrBpEdChNiYPEcXk"
}
}
The return value of the createAndOrLoginPlayer
call is found within a data
object. The return value is the player token generated from the Login
call. We did it! Congratulations! That should feel very good.
Let’s feel even better - let’s go take a look at the Player collection under the “COLLECTIONS” tab. You should see your newly created player.
Notice that the credentials that were used to create the user are not shown here. Credentials are a special, encrypted field in FaunaDB that will never be returned.
OK, one more test. Run the query one more time. This should log in your player again, returning a different token, but not create a new record. Do you still see only one record? Nice.
Completing The Permissions Setup
Now that we have the first part of our permissions working, we need to design and implement the second part, which is having the logged in player fetch a list of all user names. Now that we are practiced, this shouldn’t take us too long, but I do need to share one more aspect about Roles that will help us.
As you saw previously, Roles are used by Keys and Functions to give them access to resources, and, more specifically, to specify what actions these Keys and Functions can perform on these resources. Keys and Functions actually have a role
field, which ties them to a Role. Tokens, on the other hand, require a different method for associating themselves with Roles; Tokens rely on Role Memberships.
When a role is created, along with the permissions, a list of memberships can be provided. Each membership is associated with a single collection. In the simplest configuration of a membership, any member of this collection, as long as they have a valid login token, is associated with this role, and has the permissions ascribed to it.
With this new found feature of roles, let’s design the final portion of our player permissions:
This diagram looks much like the first diagram except instead of a key, there is a player token combined with a membership for the player collection. These tie into the logged-in
role which has permissions to call the all_user_names
resolver. As the resolver needs its own permissions, it ties into a role called all_user_names
. (Why not use the same name as the function?) This role grants read permissions on the Player collection as well as uses a new index called all_user_names
which only has access to the user_name
field values of the Player collection.
For those already familiar with GraphQL and FaunaDB, you may have addressed the issue of limiting access to field data on a document in a different way. Once such way would be breaking out restricted data into a separate type and creating an
allType:[Type!]
query which FaunaDB supports with no UDF required. This is also a valid approach. The value of creating a custom index and UDF, as I have done, is that our custom index only requires 1 read to resolve and is therefore faster and more efficient than using theallType
query which internally maps over all documents to generate its result.
We could create all_user_names
index through FQL as we did the one before, but let’s create it this time through the Web Console:
- Log into your FaunaDB account; you should be brought to your FaunaDB Console Home
- Go to the DB Overview for your "swp" database by clicking on the name of the database
- Go to the Indexes page by clicking on the “INDEXES” tab to the left
- Start a new Index by clicking on the “New Index” link at the top
Look at the image below and make sure your index definition looks the same:
Of course, the collection we will be searching is the Player collection. For the name of the index, we can use the same name as the function that will be using the index. This index does not have any search terms like the unique_Player_user_name
does because rather than isolate a specific subset of the collection, we want all the user names in the collection. And finally, because we want to be sure the all_user_names
function can only get at the user_name
data, we will list this single field in the “Values” section. Note how the editor puts data
before the user_name
field name; this is because the actual data that is returned from this search will be inside a data
object.
Once you have confirmed your index settings look like those above, save the index.
We now need to create the all_user_names
role. We can also use the Web Console to create Roles:
- Go to the “SECURITY” tab and take a moment to notice our “Bootstrap Key” we created above.
- Click on the “MANAGE ROLES” link and take a moment to notice our “bootstrap” and “register” roles
- Start a new Role by clicking on the “NEW ROLE” link
Look at the image below and make sure your role definition looks the same:
Referring back to our permissions diagram should help make sense of this screen. We give our role the same name as the function it services. We then only need read access to the Player collection and the all_user_names
index.
When you are sure your settings look the same, save the Role.
We will now return to the FQL shell to update the all_user_names
function:
Update(Function("all_user_names"), { name: "all_user_names",
body: Query(Lambda([],Select("data", Paginate(Match(Index("all_user_names"), []))))),
role: Role("all_user_names")
})
The all_user_names
function uses the built-in Match
function to grab all the user names using the all_user_names
index. Whenever we request a list of items, we are required to use pagination so we don’t shoot ourselves in the foot. Paginate
returns a data structure that lets us page through results in small chunks, 64 documents by default. (In this tutorial, 64 user names at a time will be plenty so we will not take time to explore the full power of pagination.) As mentioned when we set up the index, the data will be returned in a data
field, so we use the built-in Select
function to extract the array of user names from this field. Of course, we tie this function to the all_user_names
roll.
Go ahead and enter the code and execute the query.
We now only have one more resource to create: the logged-in
role that our player token will access through its membership in the Player collection:
CreateRole({
name: "logged-in",
privileges: [
{
resource: Function('all_user_names'),
actions: {
call: true
}
}
],
membership: [{
resource: Collection("Player")
}]
})
What is new here is the membership
field. This field takes an array of Membership definitions. Memberships are very flexible, and a lambda function can be provided to qualify further the terms of membership. For our example today, we will use the simplest form of membership by simply defining that membership in the Player collection is all that is needed to be attached to this role (when presenting a valid token, of course).
Food for thought: why is the
privileges
field name plural, but themembership
field name singular when they are both arrays? Perhaps we will never know.
Go ahead and enter the code and execute the query.
The Final Permissions Test
We are now ready to perform the second part of our permissions test – whether a player token has access to all the user names in the Player collection. Can you taste the anticipation? I can.
To perform this test, let’s start from the very beginning and create a new player and get back a new player token. Return to the GraphQL Playground.
Sometimes when I return to the GraphQL Playground, there is a new tab there, but my previous tab(s) are there, too. Just delete any blank tabs that may be there if they are confusing to you.
If your setup from our previous test is gone, go back through the instructions above and make sure you are using our custom key for the bearer token in the headers. Go ahead and create a new player with the createAndOrLoginPlayer
mutation, passing in different values from before. Here is an example.
mutation CreateAndOrLoginPlayer {
createAndOrLoginPlayer(user_name:"cool_user_name", password:"even_cooler_password")
}
You should get back a response similar to this:
{
"data": {
"createAndOrLoginPlayer": "qqEDtPCdvjABCDF0k_rewAINr729XiQJBmm-sqvuTAv9hJTWYB0"
}
}
Be sure to save the return string from this call – it is the player token we will need in the next step.
Now that we have a new player and a new player token, we can now test our allUserNames
query. Open a new tab and change the “authorization” header for the new tab to use the new player token in the form of a “Bearer” token as with the custom key.
Now enter the following query in the query editor:
query AllUserNames {
allUserNames
}
This query is much simpler than the mutation before as it simply calls a parameterless query and waits for the return values.
Now go ahead and execute the query; if all goes well, you should see two user names returned!
{
"data": {
"allUserNames": [
"cool_user_name",
"example_user_name"
]
}
}
Two user names, just like we expected! (Insert 🎆 fireworks 🎇 here.)
But… not to celebrate too early, shall we run an experiment? In our testing we have tested that the custom key and player token do most certainly have permission to perform their intended behaviors. But what if we were to switch the token for the key? Can the custom key fetch user names? Can a player token be used to create a new player?
Let’s give it a go, shall we?
Return to the GraphQL Playground tab where you executed the allUserNames
query and replace the player token in the “authorization” header with the custom key and rerun the query. What do you see? This is what I see:
{
"errors": [
{
"message": "Insufficient privileges to perform the action.",
"extensions": {
"code": "permission denied"
}
}
]
}
So far so good. Now go to the first tab where you ran the createAndOrLoginPlayer
mutation, and replace the custom key in the “authorization” header for the player token and rerun the mutation. What do you see? This is what I see:
{
"errors": [
{
"message": "Insufficient privileges to perform the action.",
"extensions": {
"code": "permission denied"
}
}
]
}
We have now verified we have a secure setup for creating and logging in players, and limiting access to user names to only trusted players.
Security is a good feeling, isn’t it?
The solution we have just implemented for identity management is secure but limited. FaunaDB's identity management feature set is capable of providing much more robust identity management for both service-to-service and stateless client architectures. Rather than forcing the developer into an opinionated solution, FaunaDB provides the building blocks for you to either roll your own, as we have done (in part), or to easily tie into 3rd party identity management providers.
In part 2 of our tutorial, we are going to focus on the Shall We Play? Elm application, and, more specifically, the elm-graphql package. Using elm-graphql, we will update the application to use our new GraphQL service which provides secure login and authorized access to player data.
I can hardly wait! See you then.
Posted on July 21, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.