Inconsistent security behavior when using UDFs with GraphQL

I’m struggling with some ABAC roles as they pertain to GraphQL relationships and I was hoping to get some clarity here. The issue is that when I have a GraphQL query that runs a custom UDF, and that UDF runs as a custom Role, even if the Role has permission to read the relationship data, it’s not returned in the GraphQL response.

Here’s an example of what’s happening.

GraphQL schema

type Relative {
  name: String
}

type Parent {
  name: String
  relative: Relative
}

type ParentWithRelative @embedded {
  name: String
  relativeName: String
}

type Query {
  allParentsSortedByName: [Parent] @resolver(name: "allParentsSortedByName", paginated: true)
  allParentsWithRelatives: [ParentWithRelative] @resolver(name: "allParentsWithRelatives", paginated: true)
}

I have an index parentsSortedByName

CreateIndex({
  name: 'parentsSortedByName',
  source: [Collection('Parent')],
  values: [
    {
      field: ['data', 'name'],
    },
    {
      field: ['ref'],
    },
  ],
})

I have a graphql role that has permission to call the allParentsSortedByName and allParentsWithRelatives functions.

CreateRole({
  name: 'graphql',
  privileges: [
    {
      resource: Function('allParentsSortedByName'),
      actions: {
        call: true,
      },
    },
    {
      resource: Function('allParentsWithRelatives'),
      actions: {
        call: true,
      },
    },
  ],
  membership: []
})

And an internal role that has read/call access to everything

CreateRole({
  name: 'internal',
  privileges: [
    {
      resource: Collection('Relative'),
      actions: {
        read: true,
        write: false,
        create: false,
        delete: false,
        history_read: false,
        history_write: false,
        unrestricted_read: false
      }
    },
    {
      resource: Collection('Parent'),
      actions: {
        read: true,
        write: false,
        create: false,
        delete: false,
        history_read: false,
        history_write: false,
        unrestricted_read: false
      }
    },
    {
      resource: Index('parentsSortedByName'),
      actions: {
        unrestricted_read: false,
        read: true
      }
    },
    {
      resource: Function('allParentsSortedByName'),
      actions: {
        call: true
      }
    },
    {
      resource: Function('allParentsWithRelatives'),
      actions: {
        call: true
      }
    }
  ],
  membership: []
})

I have an allParentsSortedByName function that runs as the internal role

Update(Function('allParentsSortedByName'), {
  name: 'allParentsSortedByName',
  role: Role('internal'),
  body: Query(
    Lambda(
      '_',
      Let(
        {
          match: Match(Index('parentsSortedByName')),
          page: Paginate(Var('match')),
        },
        Map(Var('page'), Lambda(['name', 'ref'], Get(Var('ref')))),
      ),
    ),
  ),
})

And an allParentsWithRelatives function that also runs as the internal role

Update(Function('allParentsWithRelatives'), {
  name: 'allParentsWithRelatives',
  role: Role('internal'),
  body: Query(
    Lambda(
      '_',
      Let(
        {
          match: Match(Index('parentsSortedByName')),
          page: Paginate(Var('match')),
        },
        Map(Var('page'), Lambda(
          ['name', 'ref'], 
          Let({
            data: Select(['data'], Get(Var('ref'))),
            relative: Get(Select(['relative'], Var('data'))),
            relativeName: Select(['data', 'name'], Var('relative'))
          },
          { 
            name: Select(['name'], Var('data')),
            relativeName: Var('relativeName')
          }
        )))
      ),
    ),
  ),
})

I add 2 parents, and a relative for each one.

Create(Ref(Collection('Relative'), '1'), { data: { name: 'rel1' }} );
Create(Ref(Collection('Relative'), '2'), { data: { name: 'rel2' }} );
Create(Ref(Collection('Parent'), '1'), { data: { name: 'parent1', relative: Ref(Collection('Relative'), '1') }});
Create(Ref(Collection('Parent'), '2'), { data: { name: 'parent2', relative: Ref(Collection('Relative'), '2') }});

I create a new key with the role internal and another with the role graphql.

Using the shell logged in with the secret from the graphql key I run these and I get the expected results.

Call(Function("allParentsSortedByName"));
Call(Function("allParentsWithRelatives"));

Then I run this and get permission denied, which is what I wanted.

Get(Ref(Collection('Parent'), '1'));

Now in the GraphQL interface, using the Basic authentication, I run these, and they work as expected.

query {
  allParentsSortedByName {
    data {
      name
      relative {
        name
      }
    }
  }
  allParentsWithRelatives {
    data {
      name
      relativeName
    }
  }
}

Next I do the same with a Bearer token using the internal key, and that also works as expected.

Now I do it with a Bearer token using the graphql key. In this case allParentsWithRelatives works as expected because it’s not using relations. However (finally we get to my issue) the allParentsSortedByName query returns null for the relative. So how do I fix the above system so that I can get the relative data in the GraphQL way?

(The reason I’m using a Role to run my UDFs is because I don’t have a backend so all my tokens are public, and I want to prevent anyone from being able to run arbitrary reads.)

I have run into similar issues, but I think I have an understanding of how custom UDF roles work now.

Basically, any data access in the UDF gets performed against the role you give the UDF. So for instance, your allParentsSortedByName UDF can get and return the Parent documents as you expect.

But after that point, the data is handed off to Fauna’s GraphQL resolver. And that resolver looks at the query, and continues to get the additional data requested in the query (in this case, resolving the Relative and its name property based on a Parent document. This GraphQL-resolution is performed using the client key’s role, not the UDF’s. This is because it is happening as an additional step after the UDF ran and returned its data.

1 Like

I see. Thank you. That makes sense as to why this is happening, however it does severely limit the usefulness of Fauna with GraphQL as a purely client side solution.

It seems that as long as this is the case it won’t be possible to limit read access to Collection data by hiding it behind a UDF with a special Role. So anyone can come along and scrape my entire database (minus what I’ve been able to exclude with a predicate), or get a count of how many users I have, for example.

Since embedded types are expected to be part of the returned data structure, and not require referential data-resolution by graphql, and not covered by the roles system since they aren’t backed by a collection/document, you can use them to return arbitrary data to a user.

You can see here

The UDF result type must be a GraphQL-compatible type. If an object is returned, an equivalent type must exist in the GraphQL schema to allow users to select which fields to return. Embedded types can be used to map return types that are not associated with any existing type.

type Relative {
  name: String
}

type Parent {
  name: String
  relative: Relative
}

type ParentWithRelative @embedded {
  name: String
  relativeName: String
}

type ParentObj @embedded {
  name: String
  relative: RelativeObj
}

type RelativeObj @embedded {
  name: String
}

type Query {
  allParentsSortedByName: [ParentObj] @resolver(name: "allParentsSortedByName")
  allParentsWithRelatives: [ParentWithRelative] @resolver(name: "allParentsWithRelatives", paginated: true)
}

This is just your allParentsSortedByName but updated to include the embedded Relative if they exist. You can see that I pulled the data out of the Page so that a pure array is returned, and I pulled the data out of the documents so pure objects are returned. I’m not 100% positive this UDF will work like this, but basic idea is sound :slight_smile:

Update(Function('allParentsSortedByName'), {
  name: 'allParentsSortedByName',
  role: Role('internal'),
  body: Query(
    Lambda(
      '_',
      Let(
        {
          match: Match(Index('parentsSortedByName')),
          page: Paginate(Var('match')),
          parentDocsPage: Map(Var('page'), Lambda(['name', 'ref'], Get(Var('ref')))),
          parentDocsArray: Select('data', Var('parentDocsPage'))
        },
        Reduce(
          Lambda(['accumulator', 'parentDoc'], 
            Let({
              parentDocData: Select('data', Var('parentDoc'))
              relativeRef: Select('relative', Var('parentDocData')),
              relativeDocData: If(Exists(Var('relativeRef')), Select('data', Get(Var('relativeRef'))), null)
            })
            Append(
              Var('accumulator'), 
              Merge(Var('parentDocData'), {relative: Var('relativeDocData')})
            )
          ),
          [],
          Var('parentDocsArray')
        )
      ),
    ),
  ),
})

Thank you again. It’s not ideal because I have some heavy relationship usage, but I’ll give it a try.

As I feared, the above embedded solution works for my simple example, but my app is too complex to use it. I appreciate the idea though.

Can someone from the Fauna team tell me if this behavior is intentional or if there are plans to fix this in the future?

As it stands now Fauna’s GraphQL implementation doesn’t seem like a viable solution for purely client-side apps, which is why I chose it. I don’t love the idea of having to proxy all my calls through an AWS Lambda because that seems unnecessarily complex and an unwanted expense.

You have done excellent work providing us with all the steps to reproduce this. I do believe this might be an undesired inconsistency in how GraphQL auth works (but I’m not 100% certain yet) so I went ahead and created a ticket for it. Once our GraphQL engineers look into it we will be able to provide you with further ticket (ENG-2660 in case you talk about this issue with another Faun).

2 Likes

@databrecht do you know how is this ticket going?

@vasco3, the ticket is still being triaged so we don’t yet have any updates to share about it.

1 Like

Is there any sort of ETA on this? My project has been on hold since January because this is a security deal breaker for me.

Hi @lnr. It’s still actively being discussed, but could go in a few different directions – still being triaged as Cory said. Unfortunately we do not have any updates to share beyond that.

Closing this, since it is duplicates

At this time, we have added the ability to generate UDF’s for the top-level CRUD operations, which provides much more control over your how you apply ABAC to GraphQL. We are still working on a feature to provide UDF control of relationships, which is really what is needed to solve the linked Feature Request.