DEV Community

Cover image for Getting started with FQL, FaunaDB’s native query language - part 5
Fauna for Fauna, Inc.

Posted on • Originally published at fauna.com

Getting started with FQL, FaunaDB’s native query language - part 5

Author: Pier Bover
Date: Aug 06, 2020


Welcome back, fellow space developer!

Today, in the final article of this series, we're going to take a look at authentication and authorization in FaunaDB.

In this article:

  • Introduction
  • About tokens, keys, and secrets
  • Introduction to roles and privileges
  • Creating a server key
  • Basics of authentication
  • Authentication in your application
  • Your first custom role
  • Fine-grained privileges
  • Fine-grained memberships
  • Privileges over UDFs

Introduction

Authentication and authorization are commonly implemented in the application layer. FaunaDB follows a different approach by centralizing those right at the database.

This means that any piece of code can now become a client of your database without having to reimplement authentication or authorization:

  • Mobile apps
  • Server applications
  • Cloud functions
  • Microservices
  • Frontend web apps
  • Desktop apps
  • Etc.

Before we get to the code, let me introduce a couple of core concepts.

About tokens, keys, and secrets

FaunaDB is secure by default. To execute queries, you will always need to pass a secret which is associated either with an application key or an access token.

Secrets

Whenever you instantiate a FaunaDB client in your application, you will need to use a secret. A secret looks very much like a password:

fnADviINFNACBaG5LTgmxtf2fwpdqohworOfFGJ_
Enter fullscreen mode Exit fullscreen mode

Application keys

Like their name implies, application keys are used by your applications. Each key has its own secret and can be used any number of times on multiple applications.

You create keys manually using FQL, or via FaunaDB's dashboard. Keys do not expire until you manually delete them.

Access tokens

Tokens are somewhat similar to keys, but are used by users instead of applications. Tokens and their secrets are usually generated for you when authenticating successfully with FaunaDB, so a single user could use multiple secrets in different devices simultaneously.

Tokens can be deleted manually, or upon logging a user out. It's also possible to define an optional time-to-live setting to determine how long a token will be valid.

Introduction to roles and privileges

FaunaDB features a fine-grained authorization system based on attributes, also known as ABAC.

Custom roles and privileges

Roles grant privileges to keys and tokens to access resources in the database. The most important types of resources that you can grant access to are:

  • Collections
  • Indexes
  • User-defined functions (UDFs in short)

These privileges can range from "this role can read and delete any document of this collection" to more sophisticated behaviors such as "this role can modify this document if the logged in user is its author".

Server role

All FaunaDB databases include a special server role that can access all resources. Beware: if you're using a key with this role, you should store its secret safely and never commit it to your GIT repository.

Alt Text

Creating a server key

As explained before, if you're accessing FaunaDB from a server-side environment, you will need an application key and its secret.

The easiest way to create keys is from the security tab in FaunaDB's dashboard:

Select the Server role in the dropdown which will grant this key access to everything in the database.

Finally, click SAVE:

Alt Text

After creating your key, FaunaDB will show you its secret, which you'll use in your code. Don't forget to store it somewhere safe. It will never be displayed again.

Alt Text

You can also create keys with FQL using the CreateKey function:

CreateKey({
  role: "server"
})

// Result:

{
  ref: Key("269237655682679301"),
  ts: 1593023887265000,
  role: "server",
  secret: "fnADvIY4qyACBa1k0EGH_N4YGXiFTLuj7q_7aBjP",
  hashed_secret: "$2a$05$MPFpLVrMFV5Oszfe9lqwG.FvH.LvVeryNOH4DEd4qbOiZ5N7uzk82"
}
Enter fullscreen mode Exit fullscreen mode

About client-side keys

If you're accessing FaunaDB from a client-side environment (e.g., frontend web app), you should never use a key with a server role. Anyone would be able to read the key from your JavaScript code and gain full access to the database.

Again, don't use server keys in your frontend web app.

That said, it's certainly possible to query FaunaDB directly from your frontend apps or mobile apps by creating keys with custom roles. Depending on the security features you desire, you could go with a frontend-only approach and move the authentication flow server-side. The FaunaDB team is currently working on guidance on the best security practices regarding different authentication scenarios.

For simplicity's sake, from now on we're just going to assume short-lived access tokens are generated server-side. These tokens could then be used from any type of application.

Basics of authentication

Let's see how to solve one of the most common authentication scenarios: logging in a user with an email and a password.

Before we get into the details, let's create a new collection for our users:

CreateCollection({name: "SpaceUsers"})
Enter fullscreen mode Exit fullscreen mode

Where to store the password?

You might be tempted to store a hashed password in the user document like you've probably done with other databases:

// Don't do this!

Create(
  Collection("SpaceUsers"),
  {
    data: {
      email: "darth@empire.com",
      password: "$2y$12$XUxxWc.81aq4CKsV/..."
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

You could certainly do that if you wanted to roll your own authentication system, but FaunaDB already includes a better way which is easier to use and more secure.

Ok, so where do we store the password, and how do we use it?

I mentioned earlier that access tokens are used by users. The way to tell FaunaDB that an entity (such as a user document) "has a password" is by adding a credentials object to the metadata of a document.

With this in mind, let's create our first user:

Create(
  Collection("SpaceUsers"),
  {
    data: {
      email: "darth@empire.com"
    },
    credentials: {
      password: "iamyourfather"
    }
  }
)

// Result:

{
  ref: Ref(Collection("SpaceUsers"), "269170966886613509"),
  ts: 1592960287940000,
  data: {
    email: "darth@empire.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the credentials object is not part of the document's data and it's never returned when accessing the document. Because of that, you won't be able to expose the hashed credentials by mistake.

It really doesn't matter where these credentials are stored. All the encryption and verification of passwords is solved for you when using FaunaDB's authentication system.

Logging in

Since credentials are associated with documents, we will need to find a user's document in the SpaceUsers collection to be able to log them in.

Let's create an index to do just that, and make sure there can only be one user for each email address by setting unique to true:

CreateIndex({
  name: "SpaceUsers_by_email",
  source: Collection("SpaceUsers"),
  terms: [{field: ["data", "email"]}],
  unique: true
})
Enter fullscreen mode Exit fullscreen mode

Now, we can use the Login function in combination with the SpaceUsers_by_email index.

Login(
  Match(Index("SpaceUsers_by_email"), "darth@empire.com"),
  {
    password: "iamyourfather",
    ttl: TimeAdd(Now(), 3, 'hour')
  }
)

// Result:

{
  ref: Ref(Ref("tokens"), "269770764488540678"),
  ts: 1593532299503000,
  ttl: Time("2020-06-30T18:51:39.033543Z"),
  instance: Ref(Collection("SpaceUsers"), "269170966886613509"),
  secret: "fnEDvmsUvCACBgOyQyF20AITzhwm2fKyWe7M8Qwz11CFnU1sgQ0"
}
Enter fullscreen mode Exit fullscreen mode

The Login function first takes a reference to a document or a set produced by Match. Its second argument is an object with the password and an optional time-to-live.

If everything is ok, a new access token will be created and returned to us with a secret we can use in our application.

Obviously, if the credentials are wrong, FaunaDB will return an error:

Login(
  Match(Index("SpaceUsers_by_email"), "darth@empire.com"),
  {password: "darksidemaster"}
)

// Result:

error: authentication failed
The document was not found or provided password was incorrect.
Enter fullscreen mode Exit fullscreen mode

Authentication in your application

Let's see how we'd actually authenticate our users in a server-side JavaScript application. The approach should be very similar if you're using FaunaDB with other programming languages.

First, we'd need to import FaunaDB's driver and define a couple of constants:

const faunadb = require('faunadb');
const q = faunadb.query;
const SERVER_SECRET = "BQOyQyF20AITt7nMIqW1XzW...";
Enter fullscreen mode Exit fullscreen mode

We're hardcoding the secret here for simplicity's sake. Even in a server-side project, you should get the secret from an environment config and avoid committing it to Git with your code.

Then, we instantiate our client using the secret from our server key:

const client = new faunadb.Client({
  secret: SERVER_SECRET
});
Enter fullscreen mode Exit fullscreen mode

Finally, here's an example of an authentication function:

async function authenticate (email, password) {
  return await client.query(
    q.Login(
      q.Match(q.Index('SpaceUsers_by_email'), email),
      {password: password}
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

After a successful login, we'll get an access token document with its secret like we saw previously:

{
  ref: Ref(Ref("tokens"), "269174603208720901"),
  ts: 1592963755720000,
  instance: Ref(Collection("SpaceUsers"), "269170966886613509"),
  secret: "fnEDvEzgHtACBQOyQyF20AITt7nMIqW1XzWCqykziZa53WyVm8E"
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a token for our user, we should be using its secret for any subsequent queries to FaunaDB on behalf of our user.

You have many options for storing the secret. Here are some examples:

  • Pure client-side: If you intend on accessing FaunaDB client-side you could send the secret back to the client and store it in memory.
  • Partial backend with cookie: If you're working on a server API you could store the secret in a session and send it back to the client using a secure cookie.
  • Partial backend with httpOnly cookie: You could also combine the above two approaches by creating two types of tokens in FaunaDB. One that could be used as a refresh token and stored in an httpOnly cookie, and another short-lived one that could be used and stored in the frontend.
  • Full blown backend: You could also decide you never want your clients receiving the secret and store the session in some cache and send back just a session id.

These examples have very different security implications which are far too vast and complex to discuss in this introductory article. You will have to decide carefully how you want to manage secrets for your particular use case.

Logging out

To log out, we use the Logout function which will destroy the token we created when logging in.

This could be our logout function:

async function logout (deleteAllTokens = false) {
  return await client.query(q.Logout(deleteAllTokens));
}
Enter fullscreen mode Exit fullscreen mode

Note that we don't need to pass any reference to the token since we instantiated the client with a token's secret.

Logout takes a single boolean parameter to determine if all the tokens associated with a user should be deleted or only the one being used with the current secret. If we had used q.Logout(true) our user Darth would now be logged out from all his devices. Take that, evil Sith lord!

Also note that Logout is actually a convenience function. You could also delete tokens manually with a reference to the token's document:

Delete(Ref(Ref("tokens"), "269174603208720901"))
Enter fullscreen mode Exit fullscreen mode

Advanced authentication

You can keep using FaunaDB's authentication system even for custom scenarios without having to roll your own system from scratch.

For example, you can create your own tokens with:

Create(Tokens(), {
  instance: Ref(Collection("SpaceUsers"), "269170966886613509"),
  ttl: TimeAdd(Now(), 3, 'hour')
})

// Result:

{
  ref: Ref(Ref("tokens"), "269776756060193286"),
  ts: 1593538013760000,
  instance: Ref(Collection("SpaceUsers"), "269170966886613509"),
  ttl: Time("2020-06-30T20:26:53.134950Z"),
  secret: "fnEDvnCHwaACBgOyQyF20AITOp_00s0UzldXKztxeEdM0Z48bxw"
}
Enter fullscreen mode Exit fullscreen mode

And, then, use these other FQL functions to customize your authentication logic:

  • Identify to check if a password is valid against a document's credentials.
  • HasIdentity to check if a current FaunaDB client is associated with a document or not.

Your first custom role

Our users can now log in, but they can't access any resource in our database. We need to create a role to give them access to collections, indexes, etc.

Keep in mind that you can also manage roles from the dashboard. If you go to the security tab and click on Manage Roles, you'll find the roles section:

Alt Text

Let's start with something simple. We'll just create a User role with a single privilege:

CreateRole({
  name: "User",
  membership: {
    resource: Collection("SpaceUsers")
  },
  privileges: [
    {resource: Collection("Spaceships"), actions: {read: true}}
  ]
})
Enter fullscreen mode Exit fullscreen mode

The SpaceUsers collection is now a member of the User role. Any token associated with a document from that collection will inherit the role's privileges, including previously created tokens.

We've also granted a single read-only privilege on any document from the Spaceships collection. Check the docs for the complete list of actions we can use to define privileges.

Darth will now be able to retrieve any Spaceships document, but he won't be able to create new documents in that collection or modify existing ones.

He won't be able to use any indexes either, but he will be able to use Get to retrieve a specific spaceship document and also list all spaceship documents using the Documents function we saw in previous articles:

Map(
  Paginate(Documents(Collection("Spaceships"))),
  Lambda("ref", Get(Var("ref")))
)
Enter fullscreen mode Exit fullscreen mode

Updating roles

Let's update the role with another privilege by using Update. Remember that we need to pass all privileges, including the ones we had previously set, because Update will replace the entire array.

Update(
  Role("User"),
  {
    privileges: [
      { resource: Collection("Spaceships"), actions: { read: true } },
      { resource: Collection("Planets"), actions: { read: true } }
    ]
  }
)
Enter fullscreen mode Exit fullscreen mode

Note that existing keys and tokens belonging to a role will be affected by the updated privileges.

Fine-grained privileges

It's also possible to create custom behaviors for privileges instead of simply using true or false.

For example, we might want Darth to be able to access his own SpaceUsers document, but we certainly don't want him poking around all users' documents to obtain their email addresses and spam them to join his empire.

We do that by using a Lambda to define any type of behavior we might need:

Update(
  Role("User"),
  {
    privileges: [
      { resource: Collection("Spaceships"), actions: { read: true } },
      { resource: Collection("Planets"), actions: { read: true } },
      {
        resource: Collection("SpaceUsers"),
        actions: {
          read: Query(
            Lambda("ref",
              Equals(
                Identity(),
                Var("ref")
              )
            )
          )
        }
      }
    ]
  }
)
Enter fullscreen mode Exit fullscreen mode

In this case, we've used this Lambda function on the read action for the SpaceUsers collection:

Query(
  Lambda("ref",
    Equals(
      Identity(),
      Var("ref")
    )
  )
)
Enter fullscreen mode Exit fullscreen mode
  • We need to use Query because we don't want Lambda to execute when we're only updating the role itself.
  • Whenever a SpaceUsers document is accessed, FaunaDB will trigger the Lambda and pass a reference of the document it's checking. Access will be granted only if that Lambda returns true.
  • Identity will return a reference to the document associated with the current token in use. In our example, it would return the document in the SpaceUsers collection for the current logged in user.
  • Equals will return true or false when comparing the reference returned by Identity to the reference of the document we're trying to read.

In plain English: "if the document in SpaceUsers is the same as the document we've logged in with, return true, otherwise return false".

To test this, let's create a new user:

Create(
  Collection("SpaceUsers"),
  {
    data: {
      email: "yoda@jedi.com"
    },
    credentials: {
      password: "thereisnotry"
    }
  }
)

// Result:

{
  ref: Ref(Collection("SpaceUsers"), "269412903498547719"),
  ts: 1593191016630000,
  data: {
    email: "yoda@jedi.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

So now, if we try to access Yoda's document using Darth's token in our application, we will get an error:

try {
  const result = await client.query(
    q.Get(q.Ref(q.Collection("SpaceUsers"), "269412903498547719"))
  )
} catch (error) {
  console.log(error);
}

// Result:

[PermissionDenied: permission denied] {
  name: 'PermissionDenied',
  message: 'permission denied',
  description: 'Insufficient privileges to perform the action.',
 ...
Enter fullscreen mode Exit fullscreen mode

But it will work fine if we try to access Darth's document:

q.Get(q.Ref(q.Collection("SpaceUsers"), "269412903498547719"))

// Result:

{
  ref: Ref(Collection("SpaceUsers"), "269170966886613509"),
  ts: 1592960287940000,
  data: { email: 'darth@empire.com' }
}
Enter fullscreen mode Exit fullscreen mode

Fine-grained memberships

Just as we can use Lambda to define custom behaviors to check if a role can do something, we can also create fine-grained memberships and determine which documents on a collection are members of a role.

Let's create a new user to test this:

Create(
  Collection("SpaceUsers"),
  {
    data: {
      email: "han@solo.com",
      isPilot: true
    },
    credentials: {
      password: "dontgetcocky"
    }
  }
)

// Result:

{
  ref: Ref(Collection("SpaceUsers"), "269417695003279879"),
  ts: 1593195586136000,
  data: {
    email: "han@solo.com",
    isPilot: true
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's create a new Pilot role that will only grant permissions to users with the isPilot property. We do that by adding a predicate function to the membership object:

CreateRole({
  name: "Pilot",
  membership: {
    resource: Collection("SpaceUsers"),
    predicate:
      Query(
        Lambda(
          "ref",
          Select(["data","isPilot"], Get(Var("ref")), false)
        )
      )
  },
  privileges: [
    {resource: Collection("Spaceships"), actions: {create: true}}
  ]
})
Enter fullscreen mode Exit fullscreen mode

We've added a privilege that simply allows creating documents in the Spaceships collection.

Let’s look at the membership predicate function:

Query(
  Lambda(
    "ref",
    Select(
      ["data","isPilot"],
      Get(Var("ref")),
      false
    )
  )
)
Enter fullscreen mode Exit fullscreen mode
  • Lambda will receive a reference to a document and will return whatever Select returns.
  • Select will return the value of isPilot from the document. If the path ["data","isPilot"] doesn't exist in the document, it will return false.

In plain English: "if the document in SpaceUsers contains isPilot and is set to true, the logged in user will be able to create documents in the SpaceShips collection“.

As expected, if we try to create a new ship with Darth's token, we will get an error because the User role doesn't have that privilege:

try {
  const result = await client.query(
    q.Create(
      q.Collection("Spaceships"),
      {
        data: {
          name: "Imperial Destroyer"
        }
      }
    )
  )
  console.log(result);
} catch (error) {
  console.log(error);
}

// Result:

[PermissionDenied: permission denied] {
  name: 'PermissionDenied',
  message: 'permission denied',
  description: 'Insufficient privileges to perform the action.',
  ...
Enter fullscreen mode Exit fullscreen mode

But if we do it with Han's token instead:

const result = await client.query(
  q.Create(
    q.Collection("Spaceships"),
    {
      data: {
        name: "Millennium Falcon"
      }
    }
  )
)

// Result:

{
  ref: Ref(Collection("Spaceships"), "269419218694308358"),
  ts: 1593197039260000,
  data: { name: 'Millennium Falcon' }
}
Enter fullscreen mode Exit fullscreen mode

Privileges over UDFs

We can grant privileges on UDFs just as we can on collections and indexes.

Let's create a simple function that opens the hatch of a spaceship and also writes an entry to the log:

CreateFunction({
  name: "OpenHatch",
  body: Query(
    Lambda("shipRef",
      Do(
        Update(
          Var("shipRef"),
          Let({
            shipDoc: Get(Var("shipRef")),
          }, {
            data:{
              hatchIsOpen: true
            }
          })
        ),
        Create(
          Collection("ShipLogs"),
          {
            data: {
              spaceshipRef: Var("shipRef"),
              status: "HATCH_OPENED",
              pilotRef: Identity()
            }
          }
        ),
        "Hatch open!"
      )
    )
  )
})
Enter fullscreen mode Exit fullscreen mode

This function will:

  1. Receive a reference to a ship
  2. Modify the ship's document and set hatchIsOpen to true.
  3. Create a new document in the ShipLogs collection.
  4. Return "Hatch open!" at the end.

If this function is unclear, I recommend going back to part 4 where we go through functions and transactions.

We'd call this function by simply passing a reference of the spaceship:

Call(
  Function("OpenHatch"),
  Ref(Collection("Spaceships"), "266356873589948946")
)
Enter fullscreen mode Exit fullscreen mode

Now, let's update the privileges to our Pilot role:

Update(Role("Pilot"), {
  privileges: [
    {
      resource: Collection("Spaceships"),
      actions: {create: true, write: true}
    },
    { resource: Collection("ShipLogs"), actions: {create: true} },
    { resource: Function("OpenHatch"), actions: {call: true} }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Other than granting our pilots the privilege to call the OpenHatch function, we're also granting privileges to the resources that the function needs to execute.

The problem is that by setting call to true, any pilot would be able to open any hatch of any ship. They could open the hatch of another spaceship by mistake while warping through a wormhole and break the space-time continuum!

That's not good. Let's make sure pilots can only open the hatch of their own ships.

First, let's assign Han to his spaceship:

Update(
  Ref(Collection("Spaceships"), "269419218694308358"),
  {
    data: {
      pilotRef: Ref(Collection("SpaceUsers"), "269417695003279879")
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

Now, let's update our role so that Han can only warp his own ship.

Update(Role("Pilot"), {
  privileges: [
    {
      resource: Collection("Spaceships"),
      actions: {create: true, write: true}
    },
    { resource: Collection("ShipLogs"), actions: {create: true} },
    {
      resource: Function("OpenHatch"),
      actions: {
        call: Query(
          Lambda(
            "shipRef",
            Let(
              {
                shipDoc: Get(Var("shipRef")),
                pilotRef: Select(["data","pilotRef"], Var("shipDoc"), null)
              },
              Equals(Identity(), Var("pilotRef"))
            )
          )
        )
      }
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

This is our Lambda:

Lambda(
  "shipRef",
  Let(
    {
      shipDoc: Get(Var("shipRef")),
      pilotRef: Select(["data","pilotRef"], Var("shipDoc"), null)
    },
    Equals(Identity(), Var("pilotRef"))
  )
)
Enter fullscreen mode Exit fullscreen mode

This Lambda is going to receive the same arguments we are using to call the function. So then, we just need to get the spaceship document and check whether the logged in user is the same as the pilot.

If we test this using Han's token on the Falcon:

const result = await client.query(
  q.Call(
    q.Function("OpenHatch"),
    q.Ref(q.Collection("Spaceships"), "269419218694308358")
  )
)
console.log(result);

// Result:

Hatch open!
Enter fullscreen mode Exit fullscreen mode

As expected, a document was created in the logs with the proper references:

{
  "ref": Ref(Collection("ShipLogs"), "269686129668653575"),
  "ts": 1593451585430000,
  "data": {
    "spaceshipRef": Ref(Collection("Spaceships"), "269419218694308358"),
    "status": "HATCH_OPENED",
    "pilotRef": Ref(Collection("SpaceUsers"), "269417695003279879")
  }
}
Enter fullscreen mode Exit fullscreen mode

If we try to call the same function with a different ship reference, we will get an error though:

try {
  const result = await client.query(
    q.Call(
      q.Function("OpenHatch"),
      q.Ref(q.Collection("Spaceships"), "266356873589948946")
    )
  )
} catch (error) {
  console.log(error);
}

// Result:

[PermissionDenied: permission denied] {
  name: 'PermissionDenied',
  message: 'permission denied',
  description: 'Insufficient privileges to perform the action.',
...
Enter fullscreen mode Exit fullscreen mode

Final conclusion (to this 5 part series)

With this article, we've finally reached the end of the series. What an adventure. We've travelled through the galaxy, worked with famous pilots, created spaceships, feeded futuristic holographic UIs with data… and hopefully, also learned some FQL along the way!

We've gone through many common scenarios and problems, but if you ever get stuck you can always get help from Fauna's community.

Don't forget you can also hit me up on Twitter: @pierb

Farewell, fellow space developer!

Latest comments (1)

Collapse
 
webintex profile image
Dennis

Thanks you very much for this series, helped a lot!