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

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

ReferencesTop image: Chandra X-ray: A Crab Nebula Walks Through Time

This is a companion discussion topic for the original entry at https://dgraph.io/blog/post/putting-it-all-together-part2/
1 Like

What are $USERROLE and ADMINISTRATOR strings?

The $USERROLE is a variable created from the property from the JWT claims object with the same name. ADMINISTRATOR is the value of that property in the JWT. These don’t have to be all caps, but it is common to do that to distinguish them quickly in the rules as JWT properties. The value is all caps to follow a pattern of enums containing values being in all caps. Does that clear it up?

Hi,

When I am trying this part out in my code, I met the problem of adding auth directive to AccessRights, which is an enum type. I copied exactly the same thing to the code, and here is the error message:
“resolving updateGQLSchema failed because input:138: Type AccessRights; has the @auth directive, but it is not applicable on types of ENUM kind.\n (Locations: [{Line: 3, Column: 4}])”

Can you please tell me how to solve this please? Thank you so much!

1 Like

@amaster507 Can you elaborate on this? Does this mean that an auth rule contained within a given type, say User type, is only applied when executing queryUser but not applied when querying a type for which User is a child? e.g.:

queryTeam() {
  name
  users { // <- not protected by auth rule on `type User`?
    username
  }
}
1 Like

It seems that it actually does secure the nested objects:

See here

J

1 Like

what I mean by this statement is that in the rule you cannot count on any other rule being applied. That is probably still vague so let me try with a little example.

Let’s say I have 3 types, User, Contact and Address. For whatever reason I only want to allow a user to see Contacts they own which is attributed through the owner field of the Contact and also only want them to be able to see addresses which are linked to contacts to which they are able to already read. So a little schema might look something like this:

type User {
  username: String! @id
}
type Contact {
  id: ID!
  name: String
  owner: User!
  address: Address
}
type Address {
  id: ID!
  street: String
  city: String
  state: String
  for: Contact @hasInverse(field: "address")
}

So let’s start with a simple ABAC rule for contacts:

@auth(
  query: { 
    rule: "query ($USERNAME: String!) { queryContact { owner(filter: { username: { eq: $USERNAME } }) { username } } }"
  }
)

The above rule locks down contacts so only their owners can see them (JWTs must contain the USERNAME of the authorized user)

And next a rule for addresses that we think might work if rules were stacked:

@auth(
  query: {
    rule: "query { queryAddress { for { id } } }"
  }
)

But the above does not work like one may think. At first glance if rules were stacked the for edge from Address pointing to Contact we might think would only return the contacts we are allowed to see, but such is not the case in rules. In the context of this rule, all contacts are visible and will later be filtered when queried. So what we need to also include is a snippet of the first rule we had to check that we are the Address.for.owner:

@auth(
  query: {
    rule: "query ($USERNAME: String!) { queryAddress { for { owner(filter: { username: { eq: $USERNAME } }) { username } } } }"
  }
)

but such is not the case in rules

To clarify again this stacking of rules does not apply in the rules context, but it does apply in nested edges context like you hinted at above and the linked posts explains.

For additional context, here is where I learned this by asking this same question:

Hello, I also encountered the same problem, can anyone solve it? The version of Dgraph is V21.03.2.
When I drop @auth directive of enum AccessRights, it will work.
Thanks

Ah, there shouldn’t be any auth rules on that enum. That was a copy paste error fleshing out rules for all the types. Sorry.

Thanks for reply.
So the corrected should as below, right?

type AccessRight @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 } } } } } }" }

  ]}

){

  id: ID!

  name: AccessRights @search

  forContact: Contact!

  forGroup: Group! @hasInverse (field: hasGrantedRights)

}

enum AccessRights {

  isAdmin #group admin

  canViewContact

  canAddContact

  canEditContact

  canDeleteContact

  canViewPhone

  canEditPhone

  canAddPhone

  canDeletePhone

  #more as needed

}

Hello, I learn Dgraph and auth from your case, all thing go well. But I still have a question, how to manager token lifecycle in Dgraph? I found even though the token has expired, the dgraph still return the data.

If the token is expired it should return an error.

Edit: just verified it does throw a GraphQL error.

Should I throw a graphql error in resover? how to throw it in node js?
where is the action of verified should take placed? the resover too?

There is so little information about this, any detail will be appreciated.