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.

4 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.

2 Likes

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

@pawan, this sure would simplify many of my auth rules. Is there any update on this?

For further use caseā€¦

type Contact @auth(
  query: {or: [
    # 16 rules here
  ]}
  # more auth rules here
) {
  id: ID
  isType: ContactType
  access: [ACL] # my own type declared outside of this example
  hasStaff: [Staff] @hasInverse(field: contact)
  staffOf: [Staff] @hasInverse(field: of)
}

enum ContactType {
  person
  business
}

type Staff @auth(
  query: { rule: "query { queryStaff { of { id } contact { id } } }" }
  # more auth rules here
) {
  id: ID
  isCurrent: boolean!
  of: Contact!
  contact: Contact!
}

My single query auth rule on the Staff type does not work because it does not scope it in the realm of also checking the contact rules.

So if I want to create a rule based upon another type with itā€™s rules, I have to recreate the entire rule set. In this case all 16 rules for everything I want to control access based on the access of the linked Contact.

@amaster507

Apologies for not getting back about this earlier

@michaelcompton, @arijit

What do you think about this? I think this request does make sense. There should be a way to use the filters defined in Contact above while evaluating the auth rules for a Staff. I believe this shouldnā€™t be the default behavior though. How about something like below?

type Staff @auth(
  query: { rule: "query { queryStaff { of { id } contact @authRules { id } } }" }
  # more auth rules here
)

We parse the query and if @authRules or some other directive is supplied we also apply the auth rules for that type (Contact) while evaluating the query for the current type (Staff).

1 Like

Yes. If we can do something like above then it will simplify the schema and before validating the auth rules we can do some preprocessing to expand the auth rules.

1 Like

Yeah, the basic issue here is that we donā€™t compile auth rules into other auth rules. Thereā€™s two reasons for the current behaviour: firstly, to make each rule ā€˜self containedā€™ so that you donā€™t need to understand all the rules to understand a single rule, and secondly because itā€™s pretty easy to get into recursive situations where rules depend on each other in a cycle.

The initial plan was that because the rules are all GraphQL, weā€™d move towards being able to upload a .graphql operations file with the schema. That way you could define the rules in the operations file and because the file is GraphQL operations, you can use fragments etc to build the rules from parts, rather than rewriting everything for each rule.

That alone should solve lots of the repetition problems (and give you syntax checking, GraphQL type awareness etc in writing the rules).

Personally, I prefer to look at that as a solution than to add further directives. Itā€™d work out effectively the same - e.g. instead of contact @authRules { id } youā€™d define for example a contactNotDeleted fragment and write ...contactNotDeleted. I think it would work out the same kind of expressive power and keep the rules a single statement, rather than depending on each other.

@amaster507 what do you think - can we cover you use cases with that sort of approach?

4 Likes

The reasoning is sound logic. The solution as it is better, does not solve my use case as I use quite a bit of conjunction logic with multiple rules which you canā€™t write into fragments.

I understand if it is simply not feasible though. I was asking for a lot of extra processing which could open ways to endless loops.

I kinda like being able to build rules looking at data the user can never see. Still thinking after finalizing my schema if there is a better way yet to do advanced custom auth rules. I feel like I can write a book about how not to do it.

Thanks for the response. Can you expand on this a bit more for me. Fragments have their limitations, so I want to understand how that would affect you here.

1 Like

I have 16 separate rules joined in a two level deep conjunction array.

@auth(
  query: {
    and: [
      #rule1
      #rule2
      or: [
        #more rules...

Some of these are JWT value rules, some of these are query rules

Why so many separate rules?

  1. If Super Admin by JWT
  2. If isPublic flag is set and isType is in public set
  3. If isUser in JWT and isPublic flag is set and isType is in restricted set
  4. If user in JWT is granted either owner/moderator/viewer
  5. If a group is granted owner/moderator/viewer and user has given rights in that group to view isType (and since we canā€™t declare variables within query like DQL, this becomes a separate query for each type)

After thought, since these graphql queries are parsed into DQL queries, will it ever be possible to write auth rules in DQL directly? This would skip the parsing stage and allow me to combine extra functionality such as var blocks.

I understand this would be for advanced users because then they have to know DQL and GQL. But it seems it is already headed in that direction with the custom directives.

1 Like

Yeah, I like the idea of using the custom JS hooks for custom auth: Implement custom JS resolvers in GraphQL. Thereā€™s also an argument for not needing the JS and just being able to write the rule as straight out DQL. Though I wonder if itā€™s best to just start with the most flexible mechanism in the hooks and then add a DQL option if that turns out to be simpler in lots of cases.

4 Likes