Putting it All Together - Dgraph Authentication, Authorization, and Granular Access Control (PART 2)

Update: On April 16th 2021, Slash GraphQL was officially renamed Dgraph Cloud. All other information below still applies.

The second in a 4-part series by community author Anthony Master.

This is the second of a four part series:

  1. Building an Access Control Based Schema
  2. Authorizing Users with JWTs and Rules
  3. Authenticating Against a Dgraph Database and Generating JWTs
  4. Bringing Authentication into the GraphQL Endpoint as a Custom Mutation

In this part we’ll look at adding authorization to our schema with the @auth directive. By the end of this part you’ll see how I’ve used authorization to protect some parts of the contacts schema we build in part 1.

PART 2 - Authorizing Users with JWTs and Rules

Before we begin, you should have already completed part 1 and have a working schema. We will build upon that schema in this article. In general a JWT is a signed set of claims that Dgraph can use to verify the claims and then use them to make authorization decisions based upon rules defined in the schema.

Authorization rules can be of two types:

  1. Value comparison within the JWT alone; or
  2. Query comparison using JWT values for inputs as needed.

We will get more into creating these JWTs in Part 3, but for now let’s look at how to use them.

Before we can use the @auth directive with JWTs, we must tell Dgraph that we want to use Authorization and define the VerificationKey, Header, Namespace, and Algo(rithm).

NOTE: If you are wanting to only restrict access without granting access you can do so without declaring the Dgraph.Authorization and it will not read any JWTs

In the bottom of your schema, add the following as a single line (keeping the # as it is important).

# Dgraph.Authorization {"VerificationKey":"YourSecretKey","Header":"auth","Namespace":"https://yourdomain.com/jwt/claims","Algo":"HS256"}

Replace the VerificationKey of YourSecretKey with something that is secret. The Header can be any name you would like, I am using auth to keep it simple. The namespaces can be almost anything you want. It is common to use an owned url to prevent conflicts in JWT claims, this url does not need to point to anything, it is just used for a reference point. See docs for more information.

We can declare four sets of rules for each type (query, add, update, delete). For this tutorial, we will focus on the query rules, but the other rules work much in the same way. The first rule we are going to add is on our Pass type. We don’t want anyone seeing our passwords, so let’s lock them down so no one can read them. We do this by looking for a claim in the JWT that will never exist.

type Pass @auth(
  query: { rule: "{ $NeverHere: { eq: \"anything\" } }" }
) {
  # Not modifying properties and edges
}

Congratulations, on writing your first rule! Now you could stop and test this out by running a mutation to add a password and try to query it back.

Let’s add a password:

mutation CreatePass {
  addPass(input: [{ password: "ABCDEF123456" }]) {
    numUids
  }
}

We can see that it was successfully added by the results: ("numUids": 1), meaning one new thing, a new pass node, was added to the database. Now let’s try to query that password:

query ReadPass {
  queryPass {
    id
    password
  }
}

We will get back an empty set. This is because we do not have access to query the Pass. Before continuing, let’s delete this dummy test data:

mutation DeletePass {
  deletePass(filter: { password: { eq: "ABCDEF123456" } }) {
    numUids
  }
}

We again can see that we actually deleted data by the results: ("numUids": 1).

NOTE: Again I mention that we are focusing on query rules and no rules have been declared for add, update, or delete. You will want to create these rules to secure data and prevent users from changing and deleting data that does not belong to them.

As we think more about this, how will we be able to authenticate users if we can not read the passwords? We can’t query the passwords, but we can use them in other auth rules. Auth rules are not stacking. When we write a rule using a query (as we will do next) we should be aware that other rules from other types are not deeply nested. This is something to keep in mind as we continue.

The next rule we will write is a simple query rule not using any variables from the JWT. These rules are great because they will apply even if a JWT header is not provided. Let’s keep our public contacts exposed to the world, while hiding non public contacts.

type Contact @auth(
  query: { rule: "query { queryContact(filter: { isPublic: true }) { id } }" }
) {
  # Not modifying properties and edges
}

That was fairly simple. An important thing to note here is that the query root must be the queryType of the Type where the rule is being applied. In this case on Contacts, our root query must be queryContact. Another thing to note is that query rules apply to the generated query* and get* queries. These query rules allow filtering using the search directives already declared in the schema. To learn more about filtering, please refer to the docs.

When writing these query rules, think of them behaving as mandating the @cascade directive. If any field in the query is not present then it equates to false (restricted access), and if every property in the graph is present, then it equates to true (granted access).

Auth rules can be combined using the logic conjunctions and, or, & not. This allows for advanced rules on a single type.

NOTE: any grouped query rules for a type will be processed parallel to one another for faster performance.

Let’s take the previous rule and combine it with another rule to allow access to a user’s own contacts. In order to do this we will expect the JWT to contain a USERNAME variable. If the JWT or variable is missing, but it is required in the rule, the rule will equate to false.

type Contact @auth(
  query: { or: [
    {rule: "query { queryContact(filter: { isPublic: true }) { id } }" }
    {rule: "query ($USERNAME: String!) { queryContact { access { grants { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } }" }
  ]}
) {
  # Not modifying properties and edges
}

Checking for group access is very similar, we just have to also check if the user has the correct granted rights in the group to see the type. Here is how the query to look for group access would look:

query ($USERNAME: String!) {
  queryContact {
    access {
      grants {
        isGroup {
          hasGrantedRights(filter: { name: { eq: isAdmin } or: { name: { eq: canViewContact } } }) {
            forContact {
              isUser(filter: { username: { eq: $USERNAME } }) {
                username
              }
            }
          }
        }
      }
    }
  }
}

So having these fundamentals let’s flush out all of the auth query rules:

type User @auth(
  query: { or: [
    # only allow site admins to see all users.
    { rule: "{ $USERROLE: { eq: \"ADMINISTRATOR\"} }" } # we have to escape quotation marks in the query
    # allows the login script to find a user with matching password
    # remember we can't query passwords, but this lets us check at login
    { rule: "query($PASSWORD: String! $USERNAME: String!) { queryUser(filter: { username: { eq: $USERNAME } }) { hasPassword(filter: { password: { eq: $PASSWORD } }) { id } } }" }
    # allow a logged in user to see their own user data.
    { and: [
      { rule: "{ $IS_LOGGED_IN: { eq: \"true\" } }" }
      { rule: "query($USERNAME: String!) { queryUser(filter: { username: { eq: $USERNAME } }) { username } }" }
    ]}
  ]}
  # TODO: # add: {  }
  # TODO: # update: {  }
  # TODO: # delete: {  }
) {
  username: String! @id
  hasPassword: Pass!
  isType: UserType! @search
  isContact: Contact
}
 
enum UserType {
  USER
  ADMIN
}
 
type Pass @auth(
  query: { rule: "{ $NeverHere: { eq: \"anything\" } }" }
) {
  id: ID!
  password: String! @search(by: [hash])
}
 
type Contact @auth(
  query: { or: [
    # allow admins to see all contacts
    { rule: "{ $USERROLE: { eq: \"ADMINISTRATOR\"} }" }
    # allow everyone to see public contacts
    { rule: "query { queryContact(filter: { isPublic: true }) { id } }" }
    # allow users to see contacts they have been granted access to individually
    { rule: "query ($USERNAME: String!) { queryContact { access { grants { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } }" }
    # allow users to see contacts they have been granted access to through a group
    { rule: """
      query ($USERNAME: String!) {
        queryContact {
          access {
            grants {
              isGroup {
                hasGrantedRights(filter: { name: { eq: isAdmin } or: { name: { eq: canViewContact } } }) {
                  forContact {
                    isUser(filter: { username: { eq: $USERNAME } }) {
                      username
                    }
                  }
                }
              }
            }
          }
        }
      }
    """ } # Using triple enclosed quotation marks we can do a block string.
    # rules for users...
    { and: [
      { rule: "{ $USERROLE: { eq: \"USER\"} }" }
      { or: [
        # allow users to see contacts that are other users
        { rule: "query { queryContact { isUser { username } } }" }
        # allow users to see contacts that are groups
        { rule: "query { queryContact { isGroup { slug } } }" }
      ]}
    ]}
  ]}
) {
  id: ID!
  name: String!
  hasPhones: [Phone]
  isPublic: Boolean @search
  access: [ACL]
  isUser: User @hasInverse(field: isContact)
  isType: ContactType! @search
  isGroup: Group @hasInverse(field: isContact)
}
 
enum ContactType {
  PERSON
  ORG
  GROUP
}
 
type Phone @auth(
  query: { or: [
    # allow site admins to see all phone numbers
    { rule: "{ $USERROLE: { eq: \"ADMINISTRATOR\"} }" }
    # allow everyone to see public phone numbers NOTE: this opens up a whole that allows public phone numbers to be seen even if the linked `forContact` is not public.
    { rule: "query { queryPhone(filter: { isPublic: true }) { id } }" }
    # allow users to see phone numbers they have been granted access to individually
    { rule: "query($USERNAME: String!) { queryPhone { access { grants { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } }" }
    # allow users to see phone numbers they have been granted access to through a group
    { rule: "query($USERNAME: String!) { queryPhone { access { grants { isGroup { hasGrantedRights(filter: { name: { eq: isAdmin } or: { name: { eq: canViewPhone } } }) { forContact { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } } } } }" }
  ]}
) {
  id: ID!
  number: String!
  forContact: Contact @hasInverse(field: hasPhones)
  isPublic: Boolean @search
  access: [ACL]
}
 
type ACL {
  id: ID!
  level: AccessLevel!
  grants: Contact
}
 
enum AccessLevel {
  VIEWER
  MODERATOR
  OWNER
}
 
type Group @auth(
  query: { or: [
    # allow site admins to see all groups
    { rule: "{ $USERROLE: { eq: \"ADMINISTRATOR\"} }" }
    # allow groups only visible to those who have been granted access in the group
    { rule: "query($USERNAME: String!) { queryGroup { hasGrantedRights { forContact { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } }" }
  ]}
) {
  slug: String! @id
  isContact: Contact!
  hasGrantedRights: [AccessRight]
}
 
type AccessRight {
  id: ID!
  name: AccessRights @search
  forContact: Contact!
  forGroup: Group!
}
 
enum AccessRights @auth(
  query: { or: [
    # allow site admins to see all access rights
    { rule: "{ $USERROLE: { eq: \"ADMINISTRATOR\"} }" }
    # allow users to see their own access rights
    { rule: "query($USERNAME: String!) { queryAccessRight { forContact { isUser(filter: { username: { eq: $USERNAME } }) { username } } } }" }
    # allow group admins to see access rights for their group
    { rule: "query($USERNAME: String!) { queryAccessRight { forGroup { hasGrantedRights(filter: { name: { eq: isAdmin } }) { forContact { isUser(filter: { username: { eq: $USERNAME } }) { username } } } } } }" }
  ]}
) {
  isAdmin
  canViewContact
  canAddContact
  canEditContact
  canDeleteContact
  canViewPhone
  canEdit Phone
  canAddPhone
  canDeletePhone
}
 
# Dgraph.Authorization {"VerificationKey":"YourSecretKey","Header":"auth","Namespace":"https://yourdomain.com/jwt/claims","Algo":"HS256"}

There is a lot going on here and I added comments in the code to help along the way. If there is something you do not understand I would love to hear your feedback.

In the next part we will discuss how to Authenticate users and generate JWTs. There are several ways to do this and many developers opt for Auth0 or similar services. We are going to look at how to do it all against the data in our database.

Huge thanks from Dgraph to Anthony for coming forward to write some great blogs. If you are using Slash GraphQL or Dgraph and would like to write a community post, reach out to Michael, Zhenni or Apoorv in our discuss community