Scope of Auth Directive Query Rules

This is more for documentation for myself and my companions, but I think it can add value to others.

TL;DR; Auth Directive queries are processed before applying any other rules. You can not simply check if you already have access to one item and use it to provide access to related items

When writing @auth directives I have tended to think on basis of queries to what the user can already see (as far as deep data). This was actually a flawed concept.

Consider the following schema:

type Contact @auth(
  query: { rule: "query { queryContact(filter: { isDeleted: false }) { id } }" }
) {
  id: ID!
  name: String
  isDeleted: Boolean @search
  hasEmails: [Email] @hasInverse(field: forContact)
}

type Email @auth(
  query: { rule: "query { queryEmail(filter: { isDeleted: false }) { id } }" }
) {
  id: ID!
  isDeleted: Boolean @search
  forContact: Contact
}

One would think that this would hide all Contacts and Emails that are deleted. And while that is partially right, that is also partially wrong. Think about what you would want to happen if a Contact is deleted, you would want the email to be deleted too. But this query would still return emails for deleted Contacts but not the contact itself:

query { 
  queryEmail { 
    email
    forContact { 
      name
    }
  }
}

Now to make this a tad bit more complicated…

If we made the Email.forContact field required (ie: forContact: Contact!), meaning that we don’t want to allow any emails to be entered without an attached contact, now we would get the dreaded GraphQL error that a non nullable field was returned null :frowning:

The way to fix this requires an extra step in the auth rule:

query {
  queryEmail(filter: { isDeleted: false }) {
    forContact(filter: { isDeleted: false }) { 
      id
    }
  }
}

And now the reason for the title of this post. Notice that we have to add the isDeleted filter on the forContact predicate event though that is already a rule on the Contact type.

Auth Directive queries are processed before applying any other rules. You can not simply check if you already have access to one item and use it to provide access to related items like this:

# does no different from the original auth directive rule as it sees ALL contacts
query {
  queryEmail(filter: { isDeleted: false }) {
    forContact {
      id
    }
  }
}

I don’t foresee this changing because the performance would greatly be impacted and it would be a breaking change and may not be the desired outcome in all circumstances. In some situations it might be helpful to base auth directives on data that is not visible to the end users, and it could be done with this method.


To the devs: Circumventing Errors caused by @auth dreictives and required predicates in the schema?

Would it be possible in a future release to do some internal cascading of required predicates to prevent auth directives from causing errors? This would also entail having parameterized cascade directives.

If a predicate in the schema is required then could it automatically be cascaded at that level if requrested. So in the illustration above with the original rules…

{ queryEmail { email } }

would return all email addresses, while

{ queryEmail { email forContact { id } } }

would return only email addresses for contacts that are not deleted without throwing the error:

Non-nullable field 'forContact' (type Contact!) was not present in result from Dgraph. GraphQL error propagation triggered.

3 Likes

This is right, deleting a contact doesn’t automatically also delete all the emails that the contact is linked with. It does delete the link between contact and email as well as the inverse link between email and contact. We don’t do cascading deletes yet, though we might look into supporting it at some point.

You should not have to do this. The auth rule should automatically be applied on forContact and you should not have to apply the isDeleted filter. If that is not what you are observing, it is a bug. Let us know if thats the case.

If you only wanted emails which also have a non-null forContact you can use the @cascade directive at the root of the query, something like

query {
  queryEmail @cascade {
    id
    forContact {
      id
      name
    }
  }
}

This directive is available in master and would also be part of 20.07 release. It doesn’t support parameters right now but would do so soon.

This is already available as an option by using the @cascade directive. I am not sure if we should make that the default behavior though. Though you bring up an interesting point that maybe @cascade in GraphQL should have a way to only make the required fields mandatory and not all the fields.

1 Like

I probably should have been more specific. In the example we are not actually deleting any data, rather adding a isDeleted flag to be able to restore “deleted” data. When just adding a flag like this none of the edges are deleted.

Yep, then this whole thing here is a bug instead of just the way it works. Still trying to decipher what is a bug vs. what is an undocumented feature, lol.

If not the default behavior, then it will throw that ugly error everytime for required fields when the linked data is not available. The data result would be the same, it would just be nice to suppress the error.

Could you please share some steps to reproduce the issue with some mutations and queries that you are doing here? Also do mention the Dgraph version that you are using.

sure will, give me just a little bit to do a full local mockup with example data and queries.

1 Like

Got your point, let me discuss this with the team and see what they think about this.

1 Like

Running docker image dgraph/standalone:master as of 7/14/2020 with the following schema:

type Contact @auth(
  query: {rule: "query { queryContact(filter: {not: {isDeleted: true}}) { id } }"}
) {
  id: ID
  name: String!
  isDeleted: Boolean @search
  hasEmail: [Email] @hasInverse(field: for)
}

type Email @auth(
  query: {rule: "query { queryEmail(filter: {not: {isDeleted: true}}) { id } }"}
) {
  id: ID!
  email: String!
  isDeleted: Boolean @search
  for: Contact! @hasInverse(field: hasEmail)
}

Add the following data:

{
  set {
    _:contact1 <dgraph.type> "Contact" .
    _:contact1 <Contact.name> "John" .
    _:contact2 <dgraph.type> "Contact" .
    _:contact2 <Contact.name> "Pete" .
    _:contact2 <Contact.isDeleted> "true" .
    _:contact3 <dgraph.type> "Contact" .
    _:contact3 <Contact.name> "Bob" .
    _:email1 <dgraph.type> "Email" .
    _:email1 <Email.email> "me@mydomain.com" .
    _:email1 <Email.isDeleted> "true" .
    _:email2 <dgraph.type> "Email" .
    _:email2 <Email.email> "john@mydomain.com" .
    _:email3 <dgraph.type> "Email" .
    _:email3 <Email.email> "pete@mydomain.com" .
    _:email4 <dgraph.type> "Email" .
    _:email4 <Email.email> "bob@mydomain.com" .
    _:contact1 <Contact.hasEmail> _:email1 .
    _:email1 <Email.for> _:contact1 .
    _:contact1 <Contact.hasEmail> _:email2 .
    _:email2 <Email.for> _:contact1 .
    _:contact2 <Contact.hasEmail> _:email3 .
    _:email3 <Email.for> _:contact2 .
    _:contact3 <Contact.hasEmail> _:email4 .
    _:email4 <Email.for> _:contact3 .
  }
}

And execute the following queries:

query getContactsWEmails {
  queryContact {
    name
    hasEmail {
      email
    }
  }
}

query getAllEmails {
  queryEmail {
    email
  }
}

query getEmailsWContacts {
  queryEmail {
    email
    for {
      name
    }
  }
}

The results are as expected.
getContactsWEmails = [John, Bob]
getAllEmails = [john@, pete@, bob@]
getEmailsWContacts = [john@ - John, bob@ - Bob]

Okay so let’s now limit email addresses to only allow query on emails where the user can also see the attached contact.

type Contact @auth(
  query: {rule: "query { queryContact(filter: {not: {isDeleted: true}}) { id } }"}
) {
  id: ID
  name: String!
  isDeleted: Boolean @search
  hasEmail: [Email] @hasInverse(field: for)
}

type Email @auth(
  query: {rule: "query { queryEmail(filter: {not: {isDeleted: true}}) { for { id } } }"}
) {
  id: ID!
  email: String!
  isDeleted: Boolean @search
  for: Contact! @hasInverse(field: hasEmail)
}

Results not as expected but rather same as above:
getContactsWEmails = [John, Bob]
getAllEmails = [john@, pete@, bob@] - I should not be able to see pete’s email
getEmailsWContacts = [john@ - John, bob@ - Bob]

To get desired effect, I needed to add a filter on the Email.for predicate such as:

type Contact @auth(
  query: {rule: "query { queryContact(filter: {not: {isDeleted: true}}) { id } }"}
) {
  id: ID
  name: String!
  isDeleted: Boolean @search
  hasEmail: [Email] @hasInverse(field: for)
}

type Email @auth(
  query: {rule: "query { queryEmail(filter: {not: {isDeleted: true}}) { for(filter: {not: {isDeleted: true}}) { id } } }"}
) {
  id: ID!
  email: String!
  isDeleted: Boolean @search
  for: Contact! @hasInverse(field: hasEmail)
}

Results as desired:
getContactsWEmails = [John, Bob]
getAllEmails = [john@, bob@]
getEmailsWContacts = [john@ - John, bob@ - Bob]

1 Like

Thanks for sharing, I’ll give this a try and get back to you.

1 Like